fix(web): persist general preferences in host settings

This commit is contained in:
Yichen Jiang
2026-08-07 16:43:59 +08:00
parent fcc3148cc9
commit 0833b29f25
78 changed files with 1153 additions and 615 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/runtime/README.md
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4
README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, default, and live service rather than putting product policy in runtime.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.

View File

@@ -4,6 +4,8 @@
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。

View File

@@ -21,6 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts'
export type { SettingsPreferenceSpec } from './settings-preference.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {

View File

@@ -0,0 +1,160 @@
/** Host-backed scalar preference synchronization for browser plugins. */
import type { Context } from 'cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
/** Domain-owned description of one scalar field in a settings namespace. */
export interface SettingsPreferenceSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/** Scalar field inside that namespace. */
field: string
/** Validate a wire value; undefined leaves the current in-process value active. */
decode(value: unknown): T | undefined
/** Apply a validated Host value without writing it back. */
sync(value: T): void
}
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one scalar preference's Host reads and writes. Reads never block
* plugin activation; writes carry the latest known namespace revision and
* teardown waits for the operation already crossing the wire.
*/
export class SettingsPreferenceController<T> {
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private revision: number | undefined
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace, field validator, and live target.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsPreferenceSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {}
/**
* Queue a Host refresh; a newer read or user write suppresses stale publication.
* @returns settlement after the queued read completes or is skipped.
*/
load(): Promise<void> {
const generation = ++this.readGeneration
return this.enqueue(() => this.read(generation))
}
/**
* Queue one user preference write. Rapid selections preserve mutation order,
* while only the latest settlement may resynchronize the live target.
* @param value - validated domain preference selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
persist(value: T): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [this.spec.field], value }],
...(this.revision === undefined ? {} : { expectedRevision: this.revision }),
})
} catch (_settingsWriteFailure) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
if (!response.result.ok) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
this.accept(response.result.value, generation === this.writeGeneration)
})
}
/**
* Stop queued operations and wait for the current wire call to settle.
* @returns settlement after the controller reaches quiescence.
*/
async dispose(): Promise<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed target callback cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace)
if (view === undefined) return
this.accept(view, generation === this.readGeneration)
}
private accept(view: SettingsNamespaceView, publish: boolean): void {
this.revision = view.revision
if (!publish || typeof view.value !== 'object' || view.value === null) return
const value = this.spec.decode((view.value as Record<string, unknown>)[this.spec.field])
if (value !== undefined) this.spec.sync(value)
}
}
/**
* Bind one controller to settings and connection invalidations on the caller's
* plugin lifecycle. Listeners exist before the initial background read starts.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned scalar preference contract.
* @returns the bound controller used by the domain's user-write callback.
*/
export function bindSettingsPreference<T>(
ctx: Context,
spec: SettingsPreferenceSpec<T>,
): SettingsPreferenceController<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsPreferenceController(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `runtime: ${spec.namespace}.${spec.field} preference`)
return controller
}

View File

@@ -0,0 +1,237 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
bindSettingsPreference, SettingsPreferenceController,
} from '../src/client/settings-preference.ts'
type Preference = 'light' | 'dark' | 'system'
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `preference-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: {},
value,
applies: 'live',
secrets: [],
revision,
}
}
function described(value: unknown, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function spec(values: Preference[]) {
return {
namespace: 'ui-test',
field: 'preference',
decode: (value: unknown): Preference | undefined =>
value === 'light' || value === 'dark' || value === 'system' ? value : undefined,
sync: (value: Preference) => { values.push(value) },
}
}
describe('SettingsPreferenceController', () => {
it('loads only a valid owned field and contains unavailable transports', async () => {
const values: Preference[] = []
const describe = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'sepia' }))
.mockResolvedValueOnce(described(null))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values))
for (let i = 0; i < 6; i++) await controller.load()
expect(values).toEqual(['dark'])
})
it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const values: Preference[] = []
const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const controller = new SettingsPreferenceController(
{ settings: { describe, mutate } } as never,
spec(values),
)
await controller.load()
const dark = controller.persist('dark')
const light = controller.persist('light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
await Promise.all([dark, light])
expect(values).toEqual(['system', 'light'])
expect(mutate).toHaveBeenNthCalledWith(1, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 5,
})
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const values: Preference[] = []
const describe = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const controller = new SettingsPreferenceController(
{ settings: { describe, mutate } } as never,
spec(values),
)
await controller.persist('dark')
await controller.persist('system')
expect(values).toEqual(['system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const values: Preference[] = []
const describe = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const controller = new SettingsPreferenceController(
{ settings: { describe, mutate } } as never,
spec(values),
)
await Promise.all([
controller.persist('dark'),
controller.persist('system'),
controller.persist('light'),
])
expect(describe).not.toHaveBeenCalled()
expect(values).toEqual(['light'])
})
it('keeps the queue usable when a target callback throws', async () => {
const describe = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }))
.mockResolvedValueOnce(described({ preference: 'sepia' }))
const controller = new SettingsPreferenceController(
{ settings: { describe } } as never,
{ ...spec([]), sync: () => { throw new Error('target failed') } },
)
await expect(controller.load()).rejects.toThrow('target failed')
await expect(controller.load()).resolves.toBeUndefined()
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const values: Preference[] = []
const controller = new SettingsPreferenceController(
{ settings: { mutate } } as never,
spec(values),
)
const dark = controller.persist('dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = controller.persist('light')
let stopped = false
const stop = controller.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
first.resolve(ok(view({ preference: 'dark' }, 1)))
await Promise.all([dark, light, stop])
await controller.persist('system')
await controller.load()
expect(mutate).toHaveBeenCalledOnce()
expect(values).toEqual([])
})
it('keeps remote-browser preferences in memory without Host calls', async () => {
const describe = vi.fn()
const mutate = vi.fn()
const controller = new SettingsPreferenceController(
{ settings: { describe, mutate } } as never,
spec([]),
'memory',
)
await controller.load()
await controller.persist('dark')
await controller.dispose()
expect(describe).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsPreference', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describe = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe } },
isLoopback: true,
} as never)
const values: Preference[] = []
const fiber = ctx.plugin({
inject: ['connection'],
apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) },
})
await fiber.await()
await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() })
ctx.emit('settings/changed', 'unrelated')
ctx.emit('settings/changed', 'ui-test')
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => { expect(values).toEqual(['system']) })
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
await Promise.resolve()
expect(describe).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory without starting a settings read', async () => {
const describe = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe } },
isLoopback: false,
} as never)
const fiber = ctx.plugin({
inject: ['connection'],
apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) },
})
await fiber.await()
await fiber.dispose()
expect(describe).not.toHaveBeenCalled()
})
})