Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md
T
creatixchu fd7f081ff1 Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser
# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
2026-07-29 05:32:53 +08:00

7.2 KiB
Raw Blame History

Agent Note:web GUI 宿主的能力可辨识目录选择 seam

状态:已实现

English | 中文

问题

web GUI 的"打开本地文件夹"流程被焊死在一种交互上:host.pickDirectory 调用编译进 dsh-host-apiproxy 的原生 OS 选择器(私有模块,仅测试注入缝)。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器(Figma Harness 802-56979)需要列举/创建原语,那是另一种交互契约,不是同一契约的另一种实现。想换交互只能改网关源码,违背仓库"一切皆插件"的立场。

决策

在 packages/host/ 落一个三包能力 seam——directory-picker(接口)、directory-picker-native、directory-picker-browse(后端)——唯一契约方法 capability() 返回可辨识联合:{ kind: 'native', pick(signal) } 或 { kind: 'browse', list(path?), createDirectory(path, name) }。网关(dsh-host-apiproxy)注入 directoryPicker,提供对应的 RPC,另一种 kind 的调用以 directory-picker-unavailable 应答。联合之所以可辨识,是因为后端差异在交互形态——压平成统一方法集会逼每个后端伪装另一方的形态。

client 侧靠 slot 组合,而非按广播分支。 ui-workspace 的两个触发表层各自声明一个 single 目录流洞(conversation.hero.workspace.directoryFlow/sidebar.workspaces.directoryFlow;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是双面包:browser half 把匹配的交互注册进两个洞——-native 是驱动 host.pickDirectory 的无渲染占用者,-browse 是应用内的选择工作区目录对话框。洞的 owner 会话(open/busy/onPicked/onCancel/onError)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(createWorkspace({path})、冲突/错误对话框、重新选择),占用者持有从 open 到所选路径之间的一切。因此一行 cordis.yml 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(single 洞)。早先的 host.describe.directoryPicker 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(ctx.slots.entries)取而代之,成为每次打开菜单的占用读取。

并入本决策的位置与策略裁决:

  • 不用 ctx.fs seam。 packages/fs/ 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 fs-sandbox 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;packages/host/ 是它消费方域的家。
  • 依赖调研(手写 vs 引入)。 Node 标准库本身就是维护中的跨平台 OS 层(readdir(withFileTypes)、homedir、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(node-file-manager、files-and-folders、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 drivelist、约七年未更的 windows-drive-letters)健康度/比例失当。browse 后端是标准库上的薄适配。
  • 隐藏条目:返回并打标。 宿主标注 hidden(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 FILE_ATTRIBUTE_HIDDEN 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
  • 符号链接:为可进入性而跟随。 用 stat 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,workspace.create 在接纳时本就做 realpath 规范化。
  • 列举层级有上限,且流式处理。 单次 list 至多返回 maxEntries 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 opendir 流入一个按名排序、容量 maxEntries + 1 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 DirectoryListing 携带必填的 truncated 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 list(path, signal) 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
  • 全盘可浏览,不做 roots 配置。 workspace.create 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
  • native 后端保留。 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 native 交互)。kind 命名:最初选了 dialog 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;native 命名的是选择器运行的位置。

曾考虑的替代方案

  • 给 ctx.fs 增加浏览方法。 否决:上述权限域耦合;且面向展示的列举契约(hidden 标志、面包屑、home 锚点)不属于存储 seam。
  • 统一方法集的 seam(pick(): path)。 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
  • apiproxy 里直接调标准库(不建 seam)。 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。
  • 引入文件管理器/盘符枚举依赖。 按上文调研否决;依赖政策要求记录于此。

后果

  • cordis.yml 决定交互形态;apps/cli 挂 -browse(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;-native 仍是宿主屏幕方案。
  • 协议新增 host.listDirectory/host.createDirectory 与四个错误码;connection fixture 提供确定性浏览树与确定性 pickDirectory 路径供无密钥组装测试使用。
  • 未来的新交互(或提供 native 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
  • ApiProxyDefaults.pickDirectory(仅测试注入)删除;测试像提供其他服务一样提供 stub ctx.directoryPicker。