The browse level now streams through opendir into a name-sorted window of maxEntries + 1 candidates (boundedInsert), so memory stays O(maxEntries) no matter how many children a directory holds and enterability probing touches only windowed candidates; a windowed broken symlink is not backfilled since the eviction already marks the level truncated. schemastery joins the package's runtime dependencies (the source launcher and isolated installs failed to resolve the value import). The folder-error dialog's Choose again goes inert while the flow hole is empty, and the withdrawal effect also keys on the open transition, so a flow can never open over a hole nobody serves.
7.3 KiB
Agent Note: A capability-discriminated directory-picker seam for the web-GUI host
Status: implemented
English | 中文
Problem
The web GUI's "Open local folder" flow was hardwired to one interaction: host.pickDirectory invoked a native OS chooser compiled into dsh-host-apiproxy (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma Harness 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance.
Decision
A three-package capability seam in packages/host/ — directory-picker (interface), directory-picker-native, directory-picker-browse (backends) — with one contract method: capability() returns a discriminated union, { kind: 'native', pick(signal) } or { kind: 'browse', list(path?), createDirectory(path, name) }. The gateway (dsh-host-apiproxy) injects directoryPicker, serves the matching RPCs, and answers directory-picker-unavailable for the other kind. The union is discriminated because the backends differ in interaction shape — flattening them into one method set would force every backend to fake the other's shape.
The client side is slot-composed, not advertisement-branched. ui-workspace's two trigger surfaces each declare a single directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are dual-face: the browser half registers the matching interaction into both holes — -native ships here with a renderless occupant driving host.pickDirectory; -browse's half (the in-app browsing dialog) lands in the stacked follow-up PR, until which a -browse composition shows no picking affordance (the documented empty-hole default). The hole's owner conversation (open/busy/onPicked/onCancel/onError) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (createWorkspace({path}), conflict/error dialog, Choose again), the occupant owns everything between open and the picked path. One cordis.yml row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (single hole). The earlier host.describe.directoryPicker advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (ctx.slots.entries) replaces it as the per-menu-open occupancy read.
Placement and policy rulings folded into this decision:
- Not the
ctx.fsseam.packages/fs/is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swappingfs-sandboxfor the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free;packages/host/is its consumer-domain home. - Dependency survey (hand-roll vs adopt). Node's stdlib is the maintained cross-platform OS layer (
readdir(withFileTypes),homedir, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (node-file-manager,files-and-folders, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (drivelistnative addon,windows-drive-letters~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - Hidden entries: return-and-flag. The host stamps
hidden(POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows'FILE_ATTRIBUTE_HIDDENis not exposed by dirents — documented limitation until a native probe pays for itself. - Symlinks: follow for enterability.
statprobes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, andworkspace.createalready canonicalizes via realpath at adoption. - Listing levels are bounded, and streamed. One
listcall returns at mostmaxEntriesrows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams viaopendirinto a name-sorted window ofmaxEntries + 1candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wireDirectoryListingcarries a requiredtruncatedflag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - Whole-filesystem scope, no roots config.
workspace.createaccepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - The native backend stays. Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the
nativeinteraction through its own dialog API). Kind naming:dialogwas the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate;nativenames where the chooser runs.
Alternatives considered
- Extend
ctx.fswith browse methods. Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. - One uniform seam method set (
pick(): path). Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - Direct stdlib calls inside apiproxy (no seam). Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
- Adopting a file-manager/drive-enumeration dependency. Rejected per the survey above; recorded here as the dependency policy requires.
Consequences
cordis.ymlchooses the interaction;apps/clicurrently mounts-native(unchanged behavior). The in-app browser PR flips that one row to-browse, swapping backend and UI together.- The wire gains
host.listDirectory/host.createDirectoryand four error codes; the connection fixture serves a deterministic browse tree and a deterministicpickDirectorypath for keyless assembled tests. - A future interaction (or an Electron provider of the
nativeinteraction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. ApiProxyDefaults.pickDirectory(test-only injection) is gone; tests provide a stubctx.directoryPickerlike any other service.