mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode
This commit is contained in:
@@ -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/connection/README.md
|
||||
README.md: faf093964a740092983e13bf88f2cccd853c3e36
|
||||
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c
|
||||
README.md: be521feb4826f9ad804f61532dee0aa85f39de7e
|
||||
README.zh.md: bd58ce649c75143b94c1e5dea0241e1f9a934e69
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The real browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the fixture and in-process carriers continue to satisfy the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`, `settings.openDocument`, `settings.update`, `settings.replace`, `settings.mutate`, `credentials.describe`, `credentials.set`, and `credentials.unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。真实浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;fixture 与进程内载体继续满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`、`settings.openDocument`、`settings.update`、`settings.replace`、`settings.mutate`、`credentials.describe`、`credentials.set` 和 `credentials.unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// editor; real schema-driven forms ride the HTTP transport.
|
||||
describe: request => ok(request, {
|
||||
writable: true,
|
||||
hasDocument: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
@@ -2399,6 +2400,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
revision: 0,
|
||||
}],
|
||||
}),
|
||||
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
|
||||
openDocument: request => ok(request, { opened: true as const }),
|
||||
update: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
@@ -2549,6 +2552,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'goal.complete': return this.api.goals.complete(request)
|
||||
case 'goal.clear': return this.api.goals.clear(request)
|
||||
case 'settings.describe': return this.api.settings.describe(request)
|
||||
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
|
||||
case 'settings.update': return this.api.settings.update(request)
|
||||
case 'settings.replace': return this.api.settings.replace(request)
|
||||
case 'settings.mutate': return this.api.settings.mutate(request)
|
||||
|
||||
@@ -53,6 +53,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
'settings.openDocument',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'settings.mutate',
|
||||
|
||||
@@ -181,7 +181,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('connection node half', () => {
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
@@ -218,7 +218,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
|
||||
@@ -216,7 +216,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
|
||||
@@ -156,7 +156,7 @@ function scriptedFace(overrides: {
|
||||
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
},
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
|
||||
update,
|
||||
replace,
|
||||
mutate,
|
||||
@@ -975,6 +975,7 @@ describe('ModelsSection', () => {
|
||||
const { face } = await mountSection()
|
||||
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
|
||||
writable: false,
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces(),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
|
||||
@@ -51,7 +51,7 @@ function api(overrides: {
|
||||
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
|
||||
},
|
||||
settings: {
|
||||
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
|
||||
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
|
||||
update: () => Promise.resolve(fail('unused')),
|
||||
replace: () => Promise.resolve(fail('unused')),
|
||||
},
|
||||
@@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
...NAMESPACES[0],
|
||||
secrets: [
|
||||
@@ -195,6 +196,7 @@ describe('edge joins', () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: 'llm-pi-ai',
|
||||
schema: {},
|
||||
@@ -221,6 +223,7 @@ describe('edge joins', () => {
|
||||
const { face, seenRefs } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({
|
||||
|
||||
@@ -48,7 +48,7 @@ async function bench() {
|
||||
settings: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'describe',
|
||||
result: { ok: true as const, value: { writable: true, namespaces: [] } },
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
|
||||
}),
|
||||
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('PermissionRow', () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
|
||||
const controller = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
@@ -87,7 +87,7 @@ describe('PermissionRow', () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
|
||||
const controller = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
@@ -111,7 +111,7 @@ describe('PermissionRow', () => {
|
||||
it('hides an unavailable namespace and disables a read-only provider', async () => {
|
||||
const absent = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
})
|
||||
@@ -121,7 +121,7 @@ describe('PermissionRow', () => {
|
||||
|
||||
const readonly = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
|
||||
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
})
|
||||
@@ -148,7 +148,7 @@ describe('PermissionRow', () => {
|
||||
})
|
||||
mount(controller)
|
||||
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
|
||||
describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
|
||||
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
|
||||
const button = await screen.findByRole('button', { name: 'Read Only' })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('permission settings store', () => {
|
||||
it('loads and writes defaultPreset with optimistic concurrency', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only', 4)],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
|
||||
@@ -115,7 +116,7 @@ describe('permission settings store', () => {
|
||||
})
|
||||
|
||||
it('hides the row when the namespace is absent and contains write failures', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
|
||||
const controller = new PermissionSettingsController({
|
||||
settings: { describe, mutate: vi.fn() } as never,
|
||||
})
|
||||
@@ -124,7 +125,7 @@ describe('permission settings store', () => {
|
||||
|
||||
const failing = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: {
|
||||
@@ -146,14 +147,14 @@ describe('permission settings store', () => {
|
||||
}>>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
|
||||
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
|
||||
const mutate = vi.fn()
|
||||
const controller = new PermissionSettingsController({
|
||||
settings: { describe, mutate } as never,
|
||||
})
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
|
||||
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
currentValue: 'read-only',
|
||||
@@ -200,7 +201,7 @@ describe('permission settings store', () => {
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const loading = idle.load()
|
||||
idle.dispose()
|
||||
read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
|
||||
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
|
||||
await loading
|
||||
expect(idle.store.getSnapshot().status).toBe('loading')
|
||||
|
||||
@@ -220,6 +221,7 @@ describe('permission settings store', () => {
|
||||
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const activeDescribe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only')],
|
||||
})))
|
||||
const active = new PermissionSettingsController({
|
||||
@@ -240,7 +242,7 @@ describe('permission settings store', () => {
|
||||
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const disposedWrite = new PermissionSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => rejectedMutation.promise,
|
||||
} as never,
|
||||
})
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: 0202d596f509feeba39a38254e8bab2fae27b649
|
||||
README.zh.md: adec73edda00d34e209772f0bcc54a994f593997
|
||||
README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
|
||||
README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
||||
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
|
||||
|
||||
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.action {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Optional settings-header action for opening a file-backed Host document. */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import css from './SettingsDocumentAction.module.css'
|
||||
|
||||
/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */
|
||||
export interface SettingsDocumentActionInjected {
|
||||
/** Provider metadata and action state owner. */
|
||||
controller: SettingsDocumentStore
|
||||
/** Bound selector hook for the controller snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<SettingsDocumentState>
|
||||
}
|
||||
|
||||
/** Header-action owner share, localized copy, and the registrant's state face. */
|
||||
export type SettingsDocumentActionProps =
|
||||
PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected
|
||||
|
||||
/**
|
||||
* Render the open-document action only after Host metadata confirms document availability.
|
||||
* @param props - header owner props, localized copy, and injected document state.
|
||||
* @returns the action, or null while unavailable or unresolved.
|
||||
*/
|
||||
export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode {
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
|
||||
useEffect(() => {
|
||||
void controller.load()
|
||||
}, [controller])
|
||||
|
||||
if (state.status !== 'ready') return null
|
||||
|
||||
return (
|
||||
<div className={css.action}>
|
||||
{state.error === null ? null : <span className={css.error} role="alert">{t('openDocument.error')}</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={state.opening}
|
||||
onClick={() => { void controller.open() }}
|
||||
>
|
||||
{t('openDocument')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Settings ownerless-copy plugin, browser half: registers everything on the
|
||||
* Settings surface that belongs to no single feature — the trigger/header
|
||||
* chrome content, the General section, and the `settings` dictionaries.
|
||||
* Feature-owned rows and sections stay with their features.
|
||||
* chrome content, local-document action, General section, and `settings`
|
||||
* dictionaries. Feature-owned rows and sections stay with their features.
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
@@ -27,6 +30,9 @@ export type {
|
||||
export type {
|
||||
GeneralSectionComponentProps,
|
||||
} from './GeneralSection.tsx'
|
||||
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
|
||||
export type { SettingsDocumentState } from './settings-document-store.ts'
|
||||
export { SettingsDocumentStore } from './settings-document-store.ts'
|
||||
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
|
||||
export type { WelcomeNoticeState } from './welcome-store.ts'
|
||||
export type { SettingsKey } from './locales.ts'
|
||||
@@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void {
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const documentController = connection.isLoopback
|
||||
? new SettingsDocumentStore(connection.api)
|
||||
: undefined
|
||||
const documentInjected = documentController === undefined
|
||||
? undefined
|
||||
: (() => {
|
||||
const useSnapshot = bindSnapshotSelector(documentController.store)
|
||||
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
|
||||
})()
|
||||
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
|
||||
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
@@ -75,15 +90,28 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
ctx.on('connection/reset', () => {
|
||||
refresh()
|
||||
refreshDocumentIfLoaded(documentController)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: welcome invalidations')
|
||||
}, 'ui-settings-general: metadata invalidations')
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () =>
|
||||
ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent))
|
||||
const action = documentInjected === undefined
|
||||
? undefined
|
||||
: deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.action',
|
||||
id: 'open-document',
|
||||
order: 0,
|
||||
locale: NS,
|
||||
inject: documentInjected,
|
||||
}, SettingsDocumentAction))
|
||||
const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () =>
|
||||
ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel))
|
||||
const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () =>
|
||||
@@ -106,9 +134,10 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => {
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
action?.dispose()
|
||||
close.dispose()
|
||||
general.dispose()
|
||||
welcome.dispose()
|
||||
}
|
||||
}, 'ui-settings-general: chrome, section, and onboarding registrations')
|
||||
}, 'ui-settings-general: chrome, action, section, and onboarding registrations')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export const zh = {
|
||||
'trigger': '设置',
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'openDocument': '打开配置文件',
|
||||
'openDocument.error': '无法打开配置文件',
|
||||
'general.nav': '通用设置',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
|
||||
@@ -24,6 +26,8 @@ export const en = {
|
||||
'trigger': 'Settings',
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'openDocument': 'Open configuration file',
|
||||
'openDocument.error': 'Could not open configuration file',
|
||||
'general.nav': 'General',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.en.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** State owner for the optional local settings-document action. */
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Browser state of the Host-owned settings document. */
|
||||
export interface SettingsDocumentState {
|
||||
/** Metadata-loading phase; unavailable means the provider has no local document or the read failed. */
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable'
|
||||
/** Whether one native-open request is in flight. */
|
||||
opening: boolean
|
||||
/** Last metadata/native-open diagnostic; UI exposes only localized copy. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Loads local-document availability and invokes the pathless Host-owned open operation. */
|
||||
export class SettingsDocumentStore {
|
||||
/** uSES-safe state source shared by the registered header action. */
|
||||
readonly store: SnapshotStore<SettingsDocumentState> = createSnapshotStore({
|
||||
status: 'idle', opening: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - loopback settings wire face that reports and opens the provider document.
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
|
||||
/**
|
||||
* Load whether the current provider owns a local document.
|
||||
* @returns after the latest metadata response updates the store.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const { result } = await this.api.settings.describe({})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = result.error.message
|
||||
})
|
||||
return
|
||||
}
|
||||
this.store.update((state) => {
|
||||
state.status = result.value.hasDocument ? 'ready' : 'unavailable'
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the loaded document once; concurrent gestures collapse behind the in-flight action.
|
||||
* @returns after the native-open request settles, or immediately when unavailable/already opening.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
const current = this.store.getSnapshot()
|
||||
if (current.status !== 'ready' || current.opening) return
|
||||
this.store.update((state) => {
|
||||
state.opening = true
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const response = await this.api.settings.openDocument({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
} catch (error) {
|
||||
this.store.update((state) => { state.error = messageOf(error) })
|
||||
} finally {
|
||||
this.store.update((state) => { state.opening = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh document availability after reconnect only when a surface has already requested it.
|
||||
* @param controller - optional loopback document state owner.
|
||||
*/
|
||||
export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
|
||||
if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -16,8 +16,9 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the settings seam validates and publishes the durable
|
||||
* welcome section, while slot conflicts fail loud in the slot core; this
|
||||
* package owns no additional event/data relationship between those systems.
|
||||
* welcome section, while slot conflicts fail loud in the slot core. The local
|
||||
* document action is browser state over typed RPC responses and is covered by
|
||||
* store/component tests rather than a Cordis runtime relationship.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
|
||||
/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
@@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
/** The five seats this plugin fills (slot name → expected component). */
|
||||
/** The seats this plugin fills for a loopback browser (slot name → expected component). */
|
||||
const SEATS = [
|
||||
['settings.trigger', TriggerContent],
|
||||
['settings.header', HeaderContent],
|
||||
['settings.action', SettingsDocumentAction],
|
||||
['settings.close', CloseLabel],
|
||||
['settings.section', GeneralSection],
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
@@ -36,6 +39,7 @@ async function bench(isLoopback = true) {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
hasDocument: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
@@ -47,11 +51,18 @@ async function bench(isLoopback = true) {
|
||||
},
|
||||
},
|
||||
}))
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
|
||||
const settingsOpenDocument = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
|
||||
isLoopback,
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument }
|
||||
}
|
||||
|
||||
/** Declare the shell's four child slots the way ui-settings' entry does. */
|
||||
/** Declare the shell's six child slots the way ui-settings' entry does. */
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{
|
||||
@@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void {
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
@@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
it('fills all six seats for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => {
|
||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
||||
const action = before.slots.entries('settings.action')[0]!
|
||||
const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||
expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
|
||||
expect(actionInjected.useSnapshot).toEqual(expect.any(Function))
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
@@ -159,10 +175,25 @@ describe('ui-settings-general apply', () => {
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.action')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||
b.ctx.emit('connection/reset')
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('keeps remote welcome acknowledgement process-local', async () => {
|
||||
const b = await bench(false)
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const entry = b.slots.entries('settings.onboarding')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
|
||||
@@ -170,6 +201,9 @@ describe('ui-settings-general apply', () => {
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
expect(b.slots.entries('settings.action')).toEqual([])
|
||||
await fiber.dispose()
|
||||
for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import type { TriggerContentProps } from '../src/client/chrome.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -54,3 +57,95 @@ describe('GeneralSection', () => {
|
||||
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsDocumentAction', () => {
|
||||
it('appears only for a file-backed provider and requests its Host-owned document', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [] },
|
||||
},
|
||||
})),
|
||||
openDocument,
|
||||
},
|
||||
} as never)
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
const action = await screen.findByRole('button', { name: 'Open configuration file' })
|
||||
fireEvent.click(action)
|
||||
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
|
||||
})
|
||||
|
||||
it('stays absent without a document and retries availability after remount', async () => {
|
||||
const describe = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'document-action-absent' as never,
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rpcId: 'document-action-ready' as never,
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
|
||||
})
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const first = render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
|
||||
expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
|
||||
first.unmount()
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
expect(describe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the action available and reports a native-open failure', async () => {
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [] },
|
||||
},
|
||||
})),
|
||||
openDocument: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-open-failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
|
||||
})),
|
||||
},
|
||||
} as never)
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file')
|
||||
expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
function response(hasDocument = false): RpcResponse<{
|
||||
writable: boolean
|
||||
hasDocument: boolean
|
||||
namespaces: []
|
||||
}> {
|
||||
return {
|
||||
rpcId: 'settings-document' as never,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { writable: true, hasDocument, namespaces: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function opened(): RpcResponse<{ opened: true }> {
|
||||
return {
|
||||
rpcId: 'settings-open' as never,
|
||||
result: { ok: true, value: { opened: true } },
|
||||
}
|
||||
}
|
||||
|
||||
function describeFailed(message: string): RpcResponse<never> {
|
||||
return {
|
||||
rpcId: 'settings-document-failed' as never,
|
||||
result: { ok: false, error: { code: 'internal', message, details: {} } },
|
||||
}
|
||||
}
|
||||
|
||||
describe('SettingsDocumentStore', () => {
|
||||
it('loads provider metadata and asks the settings domain to open its document', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(response(true)))
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({
|
||||
status: 'ready', opening: false, error: null,
|
||||
})
|
||||
await controller.open()
|
||||
expect(openDocument).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('marks absent or failed metadata unavailable without opening anything', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const absent = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response()), openDocument },
|
||||
} as never)
|
||||
await absent.load()
|
||||
await absent.open()
|
||||
expect(absent.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(openDocument).not.toHaveBeenCalled()
|
||||
|
||||
const failed = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
|
||||
} as never)
|
||||
await failed.load()
|
||||
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
|
||||
const rejected = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
|
||||
} as never)
|
||||
await rejected.load()
|
||||
expect(rejected.store.getSnapshot()).toMatchObject({
|
||||
status: 'unavailable', error: 'provider failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses concurrent open gestures and recovers after a failure', async () => {
|
||||
let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
|
||||
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response(true)), openDocument },
|
||||
} as never)
|
||||
await controller.load()
|
||||
const first = controller.open()
|
||||
const second = controller.open()
|
||||
expect(openDocument).toHaveBeenCalledOnce()
|
||||
resolveOpen({
|
||||
rpcId: 'settings-open-failed' as never,
|
||||
result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } },
|
||||
})
|
||||
await Promise.all([first, second])
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready', opening: false, error: 'no default editor',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale metadata completions and reports non-Error native failures', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof response>) => void
|
||||
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
|
||||
const describe = vi.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockResolvedValueOnce(response(true))
|
||||
let rejectOpen!: (reason?: unknown) => void
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
|
||||
},
|
||||
} as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
resolveFirst(response())
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
const opening = controller.open()
|
||||
rejectOpen('native unavailable')
|
||||
await opening
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready', opening: false, error: 'native unavailable',
|
||||
})
|
||||
|
||||
let rejectFirst!: (error: Error) => void
|
||||
const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject })
|
||||
const caught = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn()
|
||||
.mockReturnValueOnce(rejectedFirst)
|
||||
.mockResolvedValueOnce(response(true)),
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const staleRejection = caught.load()
|
||||
await caught.load()
|
||||
rejectFirst(new Error('stale offline'))
|
||||
await staleRejection
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
@@ -101,7 +101,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
||||
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
@@ -112,6 +112,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
@@ -133,18 +134,20 @@ describe('WelcomeNoticeStore', () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
||||
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
||||
})))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
@@ -154,7 +157,7 @@ describe('WelcomeNoticeStore', () => {
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
|
||||
@@ -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-settings/README.md
|
||||
README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
|
||||
README.zh.md: 9ca4810faccaa119bb194c0e41bb8232b6aff630
|
||||
README.md: de78d599b7833179339ceeb680fbd665b056bd83
|
||||
README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
|
||||
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
|
||||
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
|
||||
@@ -167,12 +167,22 @@
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 54px;
|
||||
padding: 20px 14px 8px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.actions {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */
|
||||
.close {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
</nav>
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<div className={css.actions}>{renderSlot('settings.action', {})}</div>
|
||||
<button ref={closeButton} type="button" className={css.close} onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
<span className={css.hiddenLabel}>{renderSlot('settings.close', {})}</span>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Settings shell slot contract — the canonical home of every settings slot
|
||||
* type. The shell is a pure composition face with zero copy of its own: it
|
||||
* occupies the sidebar-owned `sidebar.settings` hole and declares the slots
|
||||
* below; ALL text (trigger label, panel title, close aria, section content)
|
||||
* arrives from registrants. A feature owns its settings surface — adding a
|
||||
* below; ALL text (trigger label, panel title, header actions, close aria,
|
||||
* section content) arrives from registrants. A feature owns its settings surface — adding a
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
@@ -29,6 +29,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* Absent contribution leaves the heading empty.
|
||||
*/
|
||||
'settings.header': { kind: 'single'; scope: 'root'; owner: SettingsHeaderOwnerProps }
|
||||
/**
|
||||
* Optional actions rendered in the content-column header before Close.
|
||||
* Registrants own visibility, behavior, copy, and failure presentation;
|
||||
* the shell supplies only the ordered render site.
|
||||
*/
|
||||
'settings.action': { kind: 'list'; scope: 'root'; owner: SettingsHeaderOwnerProps }
|
||||
/**
|
||||
* The close button's visually-hidden label text (the button itself —
|
||||
* icon, geometry, focus — is shell chrome). Absent contribution leaves
|
||||
@@ -125,6 +131,11 @@ export type SettingsRootInjected = {
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<
|
||||
'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section' | 'settings.onboarding'
|
||||
| 'settings.trigger'
|
||||
| 'settings.header'
|
||||
| 'settings.action'
|
||||
| 'settings.close'
|
||||
| 'settings.section'
|
||||
| 'settings.onboarding'
|
||||
>
|
||||
& InjectFace<SettingsRootInjected>
|
||||
|
||||
@@ -103,6 +103,7 @@ export function apply(ctx: ClientContext): void {
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
|
||||
@@ -24,10 +24,11 @@ function injectedOf(slots: SlotsService): SettingsRootInjected {
|
||||
return (entry.inject as () => SettingsRootInjected)()
|
||||
}
|
||||
|
||||
/** The shell's five child declarations (chrome, sections, and onboarding overlays). */
|
||||
/** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
|
||||
const CHILD_SPECS = {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
@@ -38,7 +39,7 @@ describe('ui-settings apply', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
})
|
||||
|
||||
it('registers the shell and declares the five child slots, before or after the declaration', async () => {
|
||||
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -124,7 +125,7 @@ describe('ui-settings apply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('unregisters the shell and collapses all five child slots on teardown', async () => {
|
||||
it('unregisters the shell and collapses every child slot on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -14,6 +14,7 @@ type Step = { id: string; order: number }
|
||||
const SEAT_CONTENT: Record<string, string> = {
|
||||
'settings.trigger': 'Settings',
|
||||
'settings.header': 'Settings Title',
|
||||
'settings.action': 'Open configuration file',
|
||||
'settings.close': 'Close',
|
||||
}
|
||||
|
||||
@@ -114,6 +115,13 @@ describe('SettingsPanel chrome seats', () => {
|
||||
expect(close.hasAttribute('aria-label')).toBe(false)
|
||||
expect(close.textContent).toContain('Close')
|
||||
})
|
||||
|
||||
it('renders header actions before the shell-owned close control', () => {
|
||||
const { renderSlot } = mount()
|
||||
openPanel()
|
||||
expect(screen.getByText('Open configuration file')).toBeTruthy()
|
||||
expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsPanel close paths', () => {
|
||||
|
||||
@@ -790,6 +790,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'settings',
|
||||
summary: 'Abstract settings service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'prepareDocument(): Promise<string | undefined>',
|
||||
jsDoc: '/**\n * Prepare the provider\'s user-editable document for a native editor. File\n * providers may materialize an absent document before returning its path;\n * non-file providers return undefined.\n * @returns the absolute local document path, or undefined for non-file storage.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
|
||||
jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 7a5e18990792d55f6eaabaf92df8d1cc0cb7c0ba
|
||||
README.zh.md: 5b5455e8c12e73af63bd54c11996e5d270a9a6f0
|
||||
README.md: 0ed3bd661cc489d1af64a3ee81aaf40d3427e638
|
||||
README.zh.md: 96acb16bf5a56a47648b388e154a34dfd40a78ea
|
||||
|
||||
@@ -38,7 +38,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带生成的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import { openNativePath } from './native-path-opener.ts'
|
||||
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
|
||||
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
@@ -339,6 +339,8 @@ export interface ApiProxyDefaults {
|
||||
workspaceRoot: string
|
||||
/** Native open-with-default-application; injectable for carrier tests. */
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native text-editor handoff; injectable for settings-document tests. */
|
||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
@@ -1385,6 +1387,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Open one Host-resolved target and map native failures onto the wire vocabulary. */
|
||||
async function openTarget(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
open: (path: string, signal: AbortSignal) => Promise<void>,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
try {
|
||||
await open(path, signal)
|
||||
return ok(request, { opened: true as const })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'path open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Open one Host-resolved path with its default application. */
|
||||
function openPath(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
const open = defaults.openPath
|
||||
?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
|
||||
return openTarget(request, path, signal, open)
|
||||
}
|
||||
|
||||
/** Open one Host-resolved text document in a native editor. */
|
||||
function openTextFile(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
const open = defaults.openTextFile
|
||||
?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
|
||||
return openTarget(request, path, signal, open)
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the credentials domain. */
|
||||
function credentialsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
|
||||
@@ -2263,25 +2307,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async openPath(request, signal) {
|
||||
try {
|
||||
const open = defaults.openPath
|
||||
?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
|
||||
await open(request.payload.path, signal)
|
||||
return ok(request, { opened: true as const })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'path open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return openPath(request, request.payload.path, signal)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2428,11 +2454,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const exposed = exposedNamespaces()
|
||||
return Promise.resolve(ok(request, {
|
||||
writable: settings.writable,
|
||||
hasDocument: settings.documentPath !== undefined,
|
||||
namespaces: settings.describe({ redactSecrets: true })
|
||||
.filter(descriptor => exposed.has(String(descriptor.ns)))
|
||||
.map(namespaceView),
|
||||
}))
|
||||
},
|
||||
async openDocument(request, signal) {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return err(request, settingsAbsent())
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
let path: string | undefined
|
||||
try {
|
||||
path = await settings.prepareDocument()
|
||||
} catch (error: unknown) {
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document preparation was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (path === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'settings provider has no local document to open',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return openTextFile(request, path, signal)
|
||||
},
|
||||
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
|
||||
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
|
||||
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface RpcMethodMap {
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
'settings.describe': SettingsApi['describe']
|
||||
'settings.openDocument': SettingsApi['openDocument']
|
||||
'settings.update': SettingsApi['update']
|
||||
'settings.replace': SettingsApi['replace']
|
||||
'settings.mutate': SettingsApi['mutate']
|
||||
|
||||
@@ -32,9 +32,18 @@ export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wi
|
||||
/** settings.describe response value. */
|
||||
export const settingsDescribeValueSchema = z.object({
|
||||
writable: z.boolean(),
|
||||
hasDocument: z.boolean(),
|
||||
namespaces: z.array(settingsNamespaceViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
|
||||
|
||||
/** settings.openDocument request payload. */
|
||||
export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>>
|
||||
|
||||
/** settings.openDocument response value. */
|
||||
export const settingsOpenDocumentValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>>
|
||||
|
||||
/** settings.update request payload. */
|
||||
export const settingsUpdateRequestSchema = z.object({
|
||||
ns: z.string().min(1),
|
||||
|
||||
@@ -53,10 +53,26 @@ export type SettingsPathOpView =
|
||||
export interface SettingsApi {
|
||||
/**
|
||||
* Describe every registered namespace: redacted layered values plus the
|
||||
* serialized schema a client renders its form from. `writable: false`
|
||||
* (read-only provider) tells the client to disable every write control.
|
||||
* serialized schema a client renders its form from. `hasDocument` reports
|
||||
* whether a file-backed provider owns a local document without exposing its
|
||||
* Host path. This method is loopback-only; `writable: false` (read-only
|
||||
* provider) tells the client to disable every write control.
|
||||
*/
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
|
||||
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
|
||||
writable: boolean
|
||||
hasDocument: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Materialize the configured local document when absent and ask the Host to
|
||||
* hand it to the platform text-document opener. macOS forces a text editor;
|
||||
* Linux and Windows use the desktop file association. The request carries
|
||||
* no path, so the browser cannot choose an arbitrary Host filesystem target.
|
||||
*/
|
||||
openDocument(
|
||||
request: RpcRequest<{}>, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's user layer (validate → persist →
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
|
||||
settingsReplaceValueSchema, settingsUpdateValueSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
@@ -132,6 +133,7 @@ export interface IApiClient {
|
||||
}
|
||||
settings: {
|
||||
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
|
||||
openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>>
|
||||
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
|
||||
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
|
||||
mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>>
|
||||
@@ -189,6 +191,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
'settings.describe': settingsDescribeValueSchema,
|
||||
'settings.openDocument': settingsOpenDocumentValueSchema,
|
||||
'settings.update': settingsUpdateValueSchema,
|
||||
'settings.replace': settingsReplaceValueSchema,
|
||||
'settings.mutate': settingsMutateValueSchema,
|
||||
@@ -449,6 +452,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: (payload, signal) => this.callUnary('settings.describe', payload, signal),
|
||||
openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal),
|
||||
update: (payload, signal) => this.callUnary('settings.update', payload, signal),
|
||||
replace: (payload, signal) => this.callUnary('settings.replace', payload, signal),
|
||||
mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal),
|
||||
|
||||
@@ -51,7 +51,8 @@ import {
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
import {
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
|
||||
settingsReplaceRequestSchema, settingsUpdateRequestSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
@@ -69,9 +70,8 @@ import {
|
||||
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
|
||||
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
|
||||
* documented on Wire); the dispatch point carries the one Wire→exact cast.
|
||||
* Every invoke receives the carrier Request's signal; methods whose contract
|
||||
* declares a signal parameter (session.search and command.execute) forward it,
|
||||
* the rest ignore it.
|
||||
* Every invoke receives the carrier Request's signal; routes whose contract
|
||||
* declares a signal parameter forward it, and the rest ignore it.
|
||||
*/
|
||||
type UnaryRoutes = {
|
||||
[K in keyof RpcMethodMap]: {
|
||||
@@ -116,6 +116,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
|
||||
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
|
||||
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
|
||||
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
|
||||
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
|
||||
'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) },
|
||||
'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Cross-platform open-with-default-application used by the local GUI carrier. */
|
||||
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
@@ -11,27 +11,26 @@ export interface PathOpenerInternals {
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** Native path-open intent; macOS distinguishes text editing from file association. */
|
||||
type PathOpenIntent = 'default' | 'text-editor'
|
||||
|
||||
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
||||
function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export async function openNativePath(
|
||||
/** Dispatch one shell-free platform command for the requested open intent. */
|
||||
async function openNativePathWithIntent(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
intent: PathOpenIntent,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', [path], signal)
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,3 +50,32 @@ export async function openNativePath(
|
||||
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export function openNativePath(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'default', internals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a text document for editing; macOS bypasses the file-type association
|
||||
* so a YAML association with a browser cannot consume the gesture.
|
||||
* @param path - absolute or host-resolvable text-document path.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export function openNativeTextFile(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'text-editor', internals)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* invalidation frames (settings/credentials/models changed).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -47,18 +47,35 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
preparedPath?: string
|
||||
}) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.readOnly = options?.readOnly ?? false
|
||||
this.path = options?.documentPath
|
||||
this.preparedPath = options?.preparedPath
|
||||
}
|
||||
|
||||
private readonly readOnly: boolean
|
||||
private readonly path: string | undefined
|
||||
private readonly preparedPath: string | undefined
|
||||
|
||||
get writable(): boolean {
|
||||
return !this.readOnly
|
||||
}
|
||||
|
||||
override get documentPath(): string | undefined {
|
||||
return this.path
|
||||
}
|
||||
|
||||
override prepareDocument(): Promise<string | undefined> {
|
||||
return Promise.resolve(this.preparedPath ?? this.documentPath)
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
@@ -146,7 +163,12 @@ const AdapterConfig = z.object({
|
||||
})
|
||||
|
||||
async function harness(options?: {
|
||||
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
|
||||
settings?: false | {
|
||||
doc?: Record<string, unknown>
|
||||
readOnly?: boolean
|
||||
documentPath?: string
|
||||
preparedPath?: string
|
||||
}
|
||||
credentials?: false | { shadowed?: string[] }
|
||||
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
|
||||
configurableProviders?: false
|
||||
@@ -205,11 +227,15 @@ describe('settings domain', () => {
|
||||
})
|
||||
|
||||
it('describes layered redacted namespaces with their secret slots', async () => {
|
||||
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
|
||||
const ctx = await harness({ settings: {
|
||||
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
|
||||
documentPath: '/tmp/custom-settings.yaml',
|
||||
} })
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.writable).toBe(true)
|
||||
expect(value.hasDocument).toBe(true)
|
||||
expect(value.namespaces).toHaveLength(1)
|
||||
const view = value.namespaces[0]!
|
||||
expect(view.ns).toBe('llm-deepseek')
|
||||
@@ -222,6 +248,62 @@ describe('settings domain', () => {
|
||||
expect(JSON.stringify(value)).not.toContain('user-secret')
|
||||
})
|
||||
|
||||
it('opens the provider-resolved document without accepting a browser path', async () => {
|
||||
const ctx = await harness({ settings: {
|
||||
documentPath: '/tmp/described-settings.yaml',
|
||||
preparedPath: '/tmp/custom-settings.yaml',
|
||||
} })
|
||||
const opened: string[] = []
|
||||
const api = createApiProxy(ctx, {
|
||||
...DEFAULTS,
|
||||
openTextFile: (path) => {
|
||||
opened.push(path)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal)))
|
||||
.toEqual({ opened: true })
|
||||
expect(opened).toEqual(['/tmp/custom-settings.yaml'])
|
||||
})
|
||||
|
||||
it('refuses to open settings when the provider has no local document', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
|
||||
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('no local document')
|
||||
})
|
||||
|
||||
it('does not prepare or open a settings document after cancellation', async () => {
|
||||
const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
|
||||
const opened: string[] = []
|
||||
const api = createApiProxy(ctx, {
|
||||
...DEFAULTS,
|
||||
openTextFile: (path) => {
|
||||
opened.push(path)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
|
||||
const cancelled = new AbortController()
|
||||
cancelled.abort()
|
||||
expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
|
||||
.toBe('cancelled')
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
|
||||
const pending = Promise.withResolvers<string | undefined>()
|
||||
prepare.mockReturnValueOnce(pending.promise)
|
||||
const duringPrepare = new AbortController()
|
||||
const opening = api.settings.openDocument(request({}), duringPrepare.signal)
|
||||
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
||||
duringPrepare.abort()
|
||||
pending.resolve('/tmp/settings.yaml')
|
||||
expect(expectErr(await opening).code).toBe('cancelled')
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
|
||||
@@ -96,7 +96,8 @@ function scriptedApi(overrides: {
|
||||
...overrides.goals,
|
||||
},
|
||||
settings: {
|
||||
describe: r => ok(r, { writable: true, namespaces: [] }),
|
||||
describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
|
||||
openDocument: r => ok(r, { opened: true as const }),
|
||||
update: err,
|
||||
replace: err,
|
||||
mutate: err,
|
||||
@@ -679,7 +680,8 @@ describe('config unary surface', () => {
|
||||
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
|
||||
const api = scriptedApi({
|
||||
settings: {
|
||||
describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
|
||||
describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
|
||||
openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
|
||||
update: record('settings.update', r => ok(r, view)),
|
||||
replace: record('settings.replace', r => ok(r, view)),
|
||||
mutate: record('settings.mutate', r => ok(r, view)),
|
||||
@@ -697,7 +699,8 @@ describe('config unary surface', () => {
|
||||
const c = client(api)
|
||||
|
||||
const described = await c.settings.describe({})
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
|
||||
expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
|
||||
expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
|
||||
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(updated.result).toEqual({ ok: true, value: view })
|
||||
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
|
||||
@@ -718,14 +721,14 @@ describe('config unary surface', () => {
|
||||
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
|
||||
|
||||
expect(seen.map(call => call.method)).toEqual([
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.providers', 'llm.models',
|
||||
])
|
||||
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[3]?.payload)
|
||||
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
|
||||
expect(seen[4]?.payload)
|
||||
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
|
||||
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
|
||||
})
|
||||
|
||||
it('rejects an invalid credential reference name at the carrier boundary', async () => {
|
||||
|
||||
@@ -220,7 +220,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
},
|
||||
settings: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
|
||||
},
|
||||
async openDocument(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
|
||||
},
|
||||
async update(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
|
||||
|
||||
@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('native path opener', () => {
|
||||
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('bypasses macOS file associations for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
|
||||
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('uses the Linux desktop association for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
|
||||
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
|
||||
@@ -36,6 +48,16 @@ describe('native path opener', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the Windows desktop association for text documents', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens with Linux xdg-open', async () => {
|
||||
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
|
||||
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })
|
||||
|
||||
@@ -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/settings/settings-local/README.md
|
||||
README.md: 344300c33879918e836b6e208b172343cc472faa
|
||||
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
|
||||
README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4
|
||||
README.zh.md: 3a290c2795e6d1944c5bce6edb99aab53f2728ee
|
||||
|
||||
@@ -24,8 +24,9 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
|
||||
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
|
||||
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
|
||||
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
|
||||
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
|
||||
- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal.
|
||||
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
|
||||
- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
|
||||
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
|
||||
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
|
||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
|
||||
- **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
|
||||
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||
- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
@@ -96,6 +96,11 @@ function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Whether an exclusive file create found an existing document. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class SettingsLocal extends Settings {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -139,6 +144,29 @@ export class SettingsLocal extends Settings {
|
||||
return true
|
||||
}
|
||||
|
||||
/** The resolved YAML/JSON document path exposed to local configuration surfaces. */
|
||||
override get documentPath(): string {
|
||||
return this.spec.filename
|
||||
}
|
||||
|
||||
/** Materialize an absent owner-only document, then return its resolved path. */
|
||||
override prepareDocument(): Promise<string> {
|
||||
return this.enqueue(async () => {
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
try {
|
||||
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
|
||||
} catch (error) {
|
||||
if (isEEXIST(error)) return
|
||||
throw error
|
||||
}
|
||||
this.text = ''
|
||||
if (!this.isClosed()) this.publish({})
|
||||
})
|
||||
return this.spec.filename
|
||||
})
|
||||
}
|
||||
|
||||
protected async load(): Promise<Record<string, unknown>> {
|
||||
let text: string
|
||||
try {
|
||||
@@ -206,34 +234,36 @@ export class SettingsLocal extends Settings {
|
||||
// failure: an existing-but-invalid document must fail loud, never be
|
||||
// silently ignored or overwritten.
|
||||
yield* super[Service.init]()
|
||||
if (!this.spec.watch) return
|
||||
const watcher = chokidarWatch(this.spec.filename, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The base init's load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
const watcher = this.spec.watch
|
||||
? chokidarWatch(this.spec.filename, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
if (watcher !== undefined) {
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The base init's load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
}
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
// Quiesce every operation chain, even when no watcher is configured.
|
||||
this.closed = true
|
||||
await watcher.close()
|
||||
await watcher?.close()
|
||||
await this.operations
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +48,37 @@ describe('resolveSpec', () => {
|
||||
describe('boot and reads', () => {
|
||||
it('resolves defaults over an absent file and reports writable', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(ctx.settings.writable).toBe(true)
|
||||
expect(ctx.settings.documentPath).toBe(path)
|
||||
})
|
||||
|
||||
it('prepares an absent owner-only document without changing resolved settings', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'nested', 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
})
|
||||
|
||||
it('preparing an existing document preserves its contents', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const contents = 'ui-theme:\n theme: light\n'
|
||||
await writeFile(path, contents)
|
||||
const ctx = await boot({ path, watch: false })
|
||||
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
|
||||
expect(await readFile(path, 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it('reads sections from an existing yaml document', async () => {
|
||||
@@ -77,6 +102,7 @@ describe('boot and reads', () => {
|
||||
it('defaults the file location under the configured harness home', async () => {
|
||||
const dir = await tempDir()
|
||||
const ctx = await boot({ dshHome: dir, watch: false })
|
||||
expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml'))
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
|
||||
|
||||
@@ -11,6 +11,10 @@ import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
failTempWrite: false,
|
||||
failDocumentCreate: false,
|
||||
holdDocumentCreate: false,
|
||||
documentCreateStarted: undefined as (() => void) | undefined,
|
||||
continueDocumentCreate: undefined as Promise<void> | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
@@ -18,6 +22,15 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) {
|
||||
state.holdDocumentCreate = false
|
||||
state.documentCreateStarted!()
|
||||
await state.continueDocumentCreate!
|
||||
}
|
||||
if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) {
|
||||
state.failDocumentCreate = false
|
||||
throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' })
|
||||
}
|
||||
if (state.failTempWrite && String(path).endsWith('.tmp')) {
|
||||
state.failTempWrite = false
|
||||
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
|
||||
@@ -33,6 +46,10 @@ const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
state.failTempWrite = false
|
||||
state.failDocumentCreate = false
|
||||
state.holdDocumentCreate = false
|
||||
state.documentCreateStarted = undefined
|
||||
state.continueDocumentCreate = undefined
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
@@ -51,6 +68,49 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
|
||||
}
|
||||
|
||||
describe('writer-lock failure cleanup', () => {
|
||||
it('skips publication when an in-flight document create completes during teardown', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, { path, watch: false })
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
const settings = ctx.settings
|
||||
settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
const published: number[] = []
|
||||
ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) })
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
let releaseCreate!: () => void
|
||||
state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve })
|
||||
state.documentCreateStarted = markStarted
|
||||
state.holdDocumentCreate = true
|
||||
|
||||
const preparing = settings.prepareDocument()
|
||||
await started
|
||||
let disposed = false
|
||||
const disposing = fiber.dispose()
|
||||
void disposing.then(() => { disposed = true })
|
||||
await vi.waitFor(() => {
|
||||
expect((settings as unknown as { closed: boolean }).closed).toBe(true)
|
||||
})
|
||||
expect(disposed).toBe(false)
|
||||
releaseCreate()
|
||||
await expect(preparing).resolves.toBe(path)
|
||||
await disposing
|
||||
expect(await readFile(path, 'utf8')).toBe('')
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces an exclusive document-create failure and releases the lock', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
state.failDocumentCreate = true
|
||||
await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/)
|
||||
await expect(access(`${path}.lock`)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
|
||||
@@ -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/settings/settings/README.md
|
||||
README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b
|
||||
README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565
|
||||
README.md: 0841624e553364fa03f7a1f1209aa72ab13d97e4
|
||||
README.zh.md: fa84ca198ba7383f8fbd7b29a53d74f2c375bd5e
|
||||
|
||||
@@ -6,6 +6,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
|
||||
## Service API
|
||||
|
||||
- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target.
|
||||
- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
|
||||
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
|
||||
- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
|
||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||
@@ -18,7 +20,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
|
||||
## Provider contract
|
||||
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionally override `documentPath` and `prepareDocument()` for one local user-editable file, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
|
||||
## Events
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## 服务 API
|
||||
|
||||
- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。Host 配置适配器据此派生可用性,而浏览器协议只暴露一个布尔能力,绝不暴露文件系统目标。
|
||||
- `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。
|
||||
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
||||
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
|
||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
## Provider 契约
|
||||
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
|
||||
## 事件
|
||||
|
||||
|
||||
@@ -403,6 +403,27 @@ export abstract class Settings extends Service {
|
||||
/** Whether {@link update} may persist through this provider. */
|
||||
abstract readonly writable: boolean
|
||||
|
||||
/**
|
||||
* Absolute path of the provider's user-editable document, when its storage
|
||||
* is one local file. Configuration surfaces use this only as availability
|
||||
* metadata; the guarded open operation resolves the path again Host-side.
|
||||
* Non-file providers leave it undefined and expose no open-document affordance.
|
||||
* @returns the absolute local document path, or undefined for non-file storage.
|
||||
*/
|
||||
get documentPath(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the provider's user-editable document for a native editor. File
|
||||
* providers may materialize an absent document before returning its path;
|
||||
* non-file providers return undefined.
|
||||
* @returns the absolute local document path, or undefined for non-file storage.
|
||||
*/
|
||||
prepareDocument(): Promise<string | undefined> {
|
||||
return Promise.resolve(this.documentPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the provider's current raw document (namespace to raw section).
|
||||
* @returns the detached raw document.
|
||||
|
||||
@@ -58,6 +58,14 @@ async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
||||
return { ctx, provider, fiber }
|
||||
}
|
||||
|
||||
describe('provider metadata', () => {
|
||||
it('does not advertise a local document unless the provider overrides it', async () => {
|
||||
const { ctx } = await boot()
|
||||
expect(ctx.settings.documentPath).toBeUndefined()
|
||||
await expect(ctx.settings.prepareDocument()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** Record every settings/updated emission. */
|
||||
function recordUpdates(ctx: Context) {
|
||||
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
|
||||
|
||||
Reference in New Issue
Block a user