From c565022c8af85c5fd3b780c6459a6939aca8c4bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:09:43 +0800 Subject: [PATCH] fix(host): derive the picker capability union from a merge-extensible map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: the seam documented a merge-extensible union but shipped a closed alias, and the gateway schema rejected any kind beyond dialog/browse — a third backend could neither implement the seam nor be advertised. The union now derives from an augmentable DirectoryPickerCapabilities map, host.describe.directoryPicker preserves unknown wire kinds, and the browse fixture applies listDirectory's root special case so creating under '/' no longer mints a '//name' identity. --- docs/cordis-catalog/services.md | 2 +- packages/client/connection/src/client/fixture.ts | 4 +++- packages/client/connection/tests/fixture.spec.ts | 16 ++++++++++++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 6 +++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +++- packages/host/apiproxy/src/api/host.ts | 5 ++++- packages/host/apiproxy/tests/rpc-schemas.spec.ts | 4 +++- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 14 ++++++++++++-- 11 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb212207b3..397e7320d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a490f0d27d..fdc258beed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -831,7 +831,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (children === undefined) { return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) } - const target = `${parent}/${request.payload.name}` + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` if (children.includes(request.payload.name)) { return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 1b413441df..3f07922f4d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -312,6 +312,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' })) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' })) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 04b2e60ef0..698dd32201 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1603,9 +1603,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, + { + name: 'DirectoryPickerCapabilities', + declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + }, { name: 'DirectoryPickerCapability', - declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', }, { name: 'DirectoryPickerDialogCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index a73b659970..d4a3a95754 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,7 +17,9 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), - directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]), + // Open string, not a literal union: unknown kinds must survive the wire so + // a merge-added capability can advertise (the client hides the affordance). + directoryPicker: z.string(), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index abdb3c468b..3de58b7bc7 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -11,8 +11,11 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' * the host display (`host.pickDirectory`); `browse` = in-app listing/creation * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method * outside the advertised kind fails with `directory-picker-unavailable`. + * The wire preserves kinds beyond the two with methods here (a merge-added + * capability advertises before its RPCs exist); the client's documented + * default for a kind it does not recognize is to hide the picking affordance. */ -export type DirectoryPickerKind = 'dialog' | 'browse' +export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {}) /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f64883c85b..939de7677e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -211,7 +211,9 @@ describe('host domain schemas', () => { const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow() + // A kind beyond the two with methods survives the wire (merge-added + // capabilities advertise; the client hides the affordance). + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other') }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 0d5c753e92..a331a2caeb 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.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/host/directory-picker/README.md -README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868 -README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42 +README.md: 0332f7df067bfa79c7505be948554e814a690c6c +README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index c1a801cf72..0332f7df06 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index c352b35b70..a9a782019d 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e184b36e1..5a6040e51f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -73,8 +73,18 @@ export interface DirectoryPickerBrowseCapability { createDirectory(path: string, name: string): Promise } -/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */ -export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability +/** + * Merge-extensible registry of interaction shapes keyed by capability kind: a + * new backend declaration-merges its shape here (the entry's `kind` literal + * must equal its key) instead of editing this package. + */ +export interface DirectoryPickerCapabilities { + dialog: DirectoryPickerDialogCapability + browse: DirectoryPickerBrowseCapability +} + +/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */ +export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities] /** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'