docs: state the Host-face rule for the browser e2e and settle the follow-ups

apps/web/tests/README.md records why these e2e type-check in the Host aggregate
and why importing a Client package there pulls its project tree into the Host
build graph, with mirroring as the standing answer. The Agent Note drops the
directory-picker face split (assessed and declined) and the grep-level gate in
favour of that README.

docs: regenerate the catalogs and retarget the moved declarations

The forwarded-event change moved three owner packages' cordis `Events`
declarations and their branded types into client-safe `./types` modules, and
the settings-scope split moves the shell spec into ui-settings-general. Point
the type-equivalence manifest and the affected Agent Note at those homes,
register the new `remote/*` event scope and the `ctx.settingsScope` service in
the catalog partition, and re-run the generators.

`$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note
fences that quote a bare member signature are marked `ignore-check`: they are
declaration fragments, not compilable units.

refactor(client): make ui-settings the settings domain's base layer

The settings-namespace transport lived in client/runtime, where every feature
could value-import it because runtime is a platform module. It belongs to the
settings domain, but moving it into ui-settings as a shared function fails
twice: the client bundle purity gate forbids cross-plugin value imports, and
ui-settings reached ui-sidebar for its shell, so any feature depending on it
closed a cycle through ui-layout and ui-theme.

Both halves move. `ctx.settingsScope` is now a cordis service — the
collaboration shape the purity gate prescribes, and the service proxy binds
`this.ctx` to the caller, so a bound scope's disposer belongs to the calling
fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its
navigation, and the nav-row projection) moves to ui-settings-general, which
already owns the chrome and the General section. What stays in ui-settings is
what carries no `ui-*` dependency: the scope service and the canonical settings
slot types, `settings.general.item` included. That type was parked in the locale
package precisely because the declarer was unreachable without a cycle; every
registrant now depends on this base layer, so it comes home.

The scope CONTRACT stays in client/runtime: a feature service accepts a scope
through its own signature without depending on the surface that binds it.

The forwarded settings invalidation replaces the deleted client-side
`settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches
`$on` through the gateway's Client half plus the allowlist's type-only subpath
rather than api-remotes' Client face: that face imports a Host-tsdown-generated
artifact, and this package is reachable from the Host build graph through its
callers.

refactor(client): reach the settings transport through ctx.settingsScope

Every feature that owns a preference row switches from value-importing a shared
binder to the settings domain's service, and declares the two injections that
binding needs: `settingsScope` for the transport and `remote` for the forwarded
invalidation it subscribes to on the caller's own context.

The rows stay with the features that own the preferences — Language with locale,
Appearance with ui-theme, Composer Enter with ui-conversation. Only their route
to the transport changes, so no settings surface moves and no feature gains a
dependency on the shell.

The `settings.general.item` slot type now arrives from ui-settings, the base
layer every registrant already depends on, which retires the re-export outlet
ui-theme kept and the parked declaration in the locale package.

client/runtime drops its settings-form and schemastery dependencies with the
transport that used them.

test(client): bind the settings transport in the specs that boot a preference row

Every bench that activates a plugin owning a preference row now supplies the two
services that plugin injects: the forwarded-event port and the scope service.
Specs that exercise no settings path get the minimal doubles; the ones that do
drive their refresh chains through `remote/host-event`, the same signal
client/runtime republishes from a forwarded frame, replacing the deleted
client-side `settings/changed` event.

Also fixes a publication defect the built-invariant gate catches once it runs:
api-remotes' invariant companion shared the allowlist module with the package
index, so rolldown hoisted it into a third chunk beside the two bundled entries
— a file the mechanically derived publication list does not carry, leaving an
installed companion unable to import it. The companion now reads the allowlist
through this package's own published `./types` subpath, which the bundle keeps
external, so each entry stays self-contained.

The dynamic-subscription cast in apiproxy is gone: after the vendored cordis
rescope, `on` accepts the rest-parameter handler directly, and the allowlist's
shape assertion still carries the safety argument.

fix(client): carry the settings-scope move across the release manifests

Rebasing onto the publishable release set replaced every manifest's dependency
block, so the packages this change touches restate their additions in the
workspace-protocol form: the base layer's own transport dependencies, and the
`ui-settings` plus `remote` edges each preference-row owner now needs.

ui-settings-general takes clsx with the shell it received, and client/runtime
drops the settings-form and schemastery dependencies that left with the
transport.

fix(api-gateway): give each $on subscription its own registration and containment

Two defects in the forwarded-event subscription table, both raised in review:

A set keyed on listener identity stored one entry when two callers subscribed the
same function object to the same event, so the first frame reached it once instead
of twice and either disposer silenced the surviving registration. Subscriptions are
now records addressed by registration, which is what "the disposer belongs to the
calling fiber" requires.

A listener declared void may still be `async`, and the synchronous `try/catch`
could not see its rejection: the promise was dropped and surfaced as an unhandled
rejection outside the documented containment. Delivery now attaches a rejection
handler when a listener returns a promise, so both failure modes are logged and
isolated alike.

Delivery also iterates a snapshot, so a listener that subscribes or disposes during
a frame no longer changes who receives that frame, and production matches the
TestRemote double instead of relying on live Set iteration order.

Both fixes are pinned by tests that fail against the previous implementation. The
double gains its own spec for the `$mount` refusal and the unsubscribed-name drop —
per-file coverage reaches it — plus a note that it propagates a throwing listener
where production contains one, so no spec mistakes it for the containment guarantee.

Three prose corrections: `assertJsonArgs` states where its throw actually surfaces
(the emitter's listener containment, not load or emit time), the browser e2e README
names every standing Client import rather than claiming one exception, and two
comments and a test title state the forwarded event instead of the deleted
client-side one.

refactor(remote): deliver forwarded frames through ctx.remote.$dispatch

The carrier used to relay each decoded frame over an internal
`remote/host-event` cordis event so the delivery port could stay off the Remote
contract. The relay was the wrong shape twice over: it put a client-face event
into a scan whose subject is the Host vocabulary, forcing a walk exemption for
something that is not a Host event at all, and it made a direct handoff between
two Client plugins look like a broadcast any plugin participates in.

`TypeRTClientRemote` now carries both roles of one surface — consumers subscribe
with `$on`, and whoever owns the Host frame sink hands frames over with
`$dispatch` — so client/runtime calls the Remote service directly and the event
declaration is gone. A cordis service method is the collaboration shape the
client bundle purity gate prescribes, and it needs no relay to satisfy it.

The trade is that the handoff is now developer-visible: any plugin holding
`ctx.remote` can synthesize a forwarded event. That is the exposure the relay
already had — `ctx.emit` was equally reachable — stated in the contract instead
of hidden behind a private subscriber.

runtime reaches `ctx.remote` through the gateway's Client face rather than
api-remotes': that face imports a Host-tsdown-generated artifact, and this
project sits in the Host build graph.

refactor(api-remotes): keep the allowlist value out of types.ts

`src/types.ts` carries only types by package convention, but it held the
forwarded-event array, so the type-only subpath published runtime code. The
array moves to `src/remote-events.ts` and `types.ts` derives its projection from
it; both compiler faces list both files, so the Host forwarding loop and the
consumer key face still read one declaration and the package's exports are
unchanged.

The invariant companion returns to an empty installer. Its dispatch-shape check
was the only reason the companion imported the allowlist, which made the two
bundled entries share a module: rolldown hoisted it into a third chunk that the
mechanically derived publication list does not carry, so an installed companion
could not import it. Dropping the check retires that coupling along with the
subpath-import and bundle-external workarounds it needed, and the shape the
check enforced at runtime is the part the Host face's `TypeRTForwardableEvent`
assertion already refuses at compile time.

test(ui-task): bind the locale plugin's new injections in its bench

The bench boots the real locale plugin, which now injects the settings-scope
service and the forwarded-event port, so it stayed pending and left `ctx.locale`
undefined. Supplies both doubles like the other benches that boot a plugin
owning a preference row.

docs: close the documentation gates for the forwarded-event surface

Regenerates the two graph catalogs and re-records every bilingual pair this
branch edited. Several pairs needed real work beyond the record:

- The generators write only the English side, so the Chinese sides of
  `event-producer-consumer` and `module-graph` had drifted: the former still
  listed the three deleted client-face events and pointed at declaration sites
  this branch moved into `types.ts` modules, and the latter carried a stale
  dependency graph.
- `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides.
- The pairing contract requires both sides to link the same target, so the
  apiproxy README and the design note now link the English note from both
  languages, and the note's code blocks are byte-identical across the pair
  (a translated comment inside a fence counts as divergence).
- `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane
  documents a discipline reviewers apply, so it belongs in the bilingual corpus
  rather than in the pairing exemption list.
- Four fences in the design note are marked `ignore-check`: each quotes a member
  signature, a union arm, or a snippet that names symbols it does not import, so
  none is a compilable unit.

docs(agent-note): transition the forwarded-event note to implemented

The design shipped in this PR, so the pair moves into `implemented/` and takes
that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`,
and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what
pins the behavior) and `## Consequences` (what the shipped shape costs).

Facts that moved after the proposal are corrected rather than preserved: the
allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`,
the delivery port is `$dispatch` rather than an internal cordis event, and the
invariant companion is an explained empty installer. `Verification` states the
two `$on` defects the review found — independent registration identity and
async-rejection containment — since those are now the properties tests pin.

Supersession is partial, so five active notes stay active and gain a
cross-link each: `web-config-plane`, `web-client-session-scope`,
`config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and
`permission-default-for-new-sessions` each described a frame this change
replaced. Only the mechanism sentence is annotated; every conclusion those
notes own is untouched, and `host/models-changed` remains apiproxy's own
derived frame in all of them.

Also pins the disposer's idempotence: calling one `$on` disposer twice must not
splice a surviving twin registration out from under its owner.

fix: docs

fix: test
This commit is contained in:
imccyu
2026-08-10 22:06:25 +08:00
parent 77ccffa35e
commit 01ecb43ebc
135 changed files with 1664 additions and 1176 deletions

View File

@@ -33,7 +33,8 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
@@ -44,7 +45,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
@@ -59,19 +59,20 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,63 @@
/**
* The settings-namespace scope contract. The type lives here, in the common
* dependency of every feature that owns a preference, while the implementation
* and its Host transport live with the Settings surface
* (`dsh-client-ui-settings`): a feature service accepts a scope through
* `attachSettings` without depending on the surface that binds it, which would
* otherwise close a reference cycle.
*/
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}

View File

@@ -1,6 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -42,9 +46,12 @@ 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 { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
// Contract only: the scope implementation and its Host transport belong to
// dsh-client-ui-settings (see that package's settings-scope.ts).
export type {
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
} from './contract/settings-scope.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
@@ -190,7 +197,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
export const inject = ['connection', 'typert', 'remote']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -219,11 +226,11 @@ export function apply(ctx: Context): void {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin only carries the frame onto the internal
// `remote/host-event` plumbing event; the Remote service subscribes there
// and fans out to `ctx.remote.$on`, so no consumer reads a frame.
// session routing). This plugin owns the frame sink, so it hands the
// decoded frame straight to the Remote service, which fans it out to
// `ctx.remote.$on` subscribers; no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args)
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}

View File

@@ -1,261 +0,0 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one namespace's Host reads and writes behind a snapshot store.
* Reads never block plugin activation; writes carry the latest known
* namespace revision and teardown waits for the operation already crossing
* the wire.
*/
export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
revision: undefined,
writable: false,
mode: persistence,
})
}
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T> {
return this.store.getSnapshot()
}
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void {
return this.store.subscribe(listener)
}
/**
* 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 field write; see {@link SettingsScope.set} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
const revision = this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
...(revision === undefined ? {} : { expectedRevision: 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 subscriber 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 { namespaces, writable } = response.result.value
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
const publish = generation === this.readGeneration
if (view === undefined) {
if (publish) {
this.store.update((draft) => {
draft.status = 'unavailable'
draft.writable = writable
})
}
return
}
this.accept(view, publish, writable)
}
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'
draft.value = decoded
})
}
private decode(view: SettingsNamespaceView): T | undefined {
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
// Sections are plain objects by construction; schemastery alone would
// resolve null or an array through object defaults instead of refusing.
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
return undefined
}
return failure === undefined ? view.value as T : undefined
}
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* caller's plugin lifecycle. Listeners exist before the initial background
* read starts, so activation never blocks on the settings transport.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
export function bindSettingsScope<T>(
ctx: Context,
spec: SettingsScopeSpec<T>,
): SettingsScope<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
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} settings scope`)
return controller
}

View File

@@ -1,352 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
bindSettingsScope, SettingsScopeController, type SettingsScope,
} from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
}
const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${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: ENVELOPE,
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 }
}
/** Record each distinct published section, starting from the current one. */
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
scope.subscribe(() => {
const value = scope.getSnapshot().value
if (value !== seen[seen.length - 1]) seen.push(value)
})
return seen
}
describe('SettingsScopeController', () => {
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
expect(scope.getSnapshot()).toEqual({
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
})
await scope.load()
expect(scope.getSnapshot()).toEqual({
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
})
})
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
.mockResolvedValueOnce(described(null, 5))
.mockResolvedValueOnce(described('scalar', 6))
.mockResolvedValueOnce(described(['queue'], 7))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const good = trackValues(scope)
for (let i = 0; i < 7; i++) await scope.load()
expect(scope.getSnapshot()).toMatchObject({
status: 'ready', value: { preference: 'dark' }, revision: 7,
})
expect(good).toEqual([undefined, { preference: 'dark' }])
})
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const statuses: string[] = []
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
const stale = scope.load()
const fresh = scope.load()
await Promise.all([stale, fresh])
expect(statuses).not.toContain('unavailable')
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
})
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().status).toBe('ready')
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
})
it('applies a custom decode override in place of the wire schema', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{
namespace: 'ui-test',
decode: section => (section as UiTestSettings).preference === 'dark'
? section as UiTestSettings
: undefined,
},
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.load()
const dark = scope.set('preference', 'dark')
const light = scope.set('preference', 'light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
await Promise.all([dark, light])
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
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 describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.set('preference', 'dark')
await scope.set('preference', 'system')
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await Promise.all([
scope.set('preference', 'dark'),
scope.set('preference', 'system'),
scope.set('preference', 'light'),
])
expect(describeCall).not.toHaveBeenCalled()
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
})
it('keeps the write queue usable when a subscriber throws', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
let thrown = false
scope.subscribe(() => {
if (thrown) return
thrown = true
throw new Error('subscriber failed')
})
await expect(scope.load()).rejects.toThrow('subscriber failed')
await expect(scope.load()).resolves.toBeUndefined()
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
})
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 describeCall = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
const dark = scope.set('preference', 'dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = scope.set('preference', 'light')
let stopped = false
const stop = scope.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 scope.set('preference', 'system')
await scope.load()
expect(mutate).toHaveBeenCalledOnce()
expect(describeCall).not.toHaveBeenCalled()
expect(published).toEqual([undefined])
})
it('keeps a remote browser in memory mode without Host calls', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
'memory',
)
expect(scope.getSnapshot()).toEqual({
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
})
await scope.load()
await scope.set('preference', 'dark')
await scope.dispose()
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsScope', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = 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: describeCall } },
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).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(describeCall).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory mode without starting a settings read', async () => {
const describeCall = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
await fiber.dispose()
expect(describeCall).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,7 @@
/**
* Wire-to-typed-event bridge: a `host/remote-event` frame is republished
* verbatim on the internal `remote/host-event` plumbing event (the Remote
* service's fan-out to `ctx.remote.$on` is api-gateway's own coverage);
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
* api-gateway's own coverage);
* host/session-preset-changed → ctx 'session/preset-changed';
* `host/models-changed` still broadcasts the typed `models/changed`; each
* established connection generation → ctx 'connection/reset' (the forced
@@ -43,13 +43,20 @@ void forwardedEventContracts
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
dispatched: unknown[][]
}
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
// Stands in for api-gateway's Remote service: this spec owns the carrier's
// handoff, not the fan-out behind it.
ctx.reflect.provide('remote', {
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
})
const handle: ConnectionHandle = {
api,
isLoopback: true,
@@ -69,8 +76,7 @@ async function mount(): Promise<Bench> {
describe('wire event bridge', () => {
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
const seen = bench.dispatched
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
@@ -86,8 +92,7 @@ describe('wire event bridge', () => {
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
const seen = bench.dispatched
bench.sinks?.onHostEnvelope?.({
rpcId: 'r3' as never,

View File

@@ -23,9 +23,6 @@
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../host/apiproxy"
},
@@ -61,6 +58,9 @@
},
{
"path": "../../typert/registry"
},
{
"path": "../../api/gateway"
}
],
"exclude": [