Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
T
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

8.5 KiB
Raw Blame History

Agent Note: dsh web config-tree boot and the web transport layering

Status: implemented

English | 中文

Scope: how dsh web composes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The client plugin loading note owns the browser-side loading chain this composition feeds.

Problem

dsh web was the only hand-assembled surface left: bootHost mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a web.ts constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the __DSH_BOOT__ graph, owned the SSE channel, and hard-coded the /api/* prefix; the dev bundle watch lived inside the prod registry behind a watch? flag with no lifecycle owner; the graph registry rescanned everything on every internal/plugin emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded $DSH_HOME/.env, so DSH_HOME=… dsh web could not find an API key living there.

Decision

Composition is one flat config tree. apps/cli/base.cordis.yml plus its surface overlay holds every row — the host runtime (32 rows), the api-gateway row, the webserver row, and the dshClient rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into apps/cli/base.cordis.yml and each surface reduced to an overlay (shared-base overlays). --dev appends the dsh-client-hmr row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: assertEntriesLoaded (import failures), installFailLoud (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).

Boot glue is a class pair. AppCLIEntry (apps/cli) and AppWebEntry (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. AppCLIEntry.run() is three stages: layered env (ambient > cwd .env > $DSH_HOME/.env, closing the defect above) → patch composition → Loader include boot plus the triple. AppWebEntry.run() mirrors it browser-side: parse window.__DSH_BOOT__ into a BootManifest (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the immediately tier in parallel with Context/Loader setup, await the prefetch before creating entries (materialization is tree.import's synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.

Config sources have one declaration place each. yml static values are engineering defaults; the profile json (./.dsh-tmp-profile/config.json, read-only, never created, cwd-anchored until the $DSH_HOME migration) is user config mapped through a static PROFILE_MAPPINGS table onto target rows (provider/model → the api-gateway row, persistenceRoot → the jsonl row); CLI flags map onto the webserver row with a field set disjoint from the json's; env values enter through yml !!js expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend distIndex rides the same patch channel — an assembly fact, not user config.

The transport splits five ways. dsh-host-apiproxy upgraded to the gateway plugin (api-gateway row): default-exports ApiProxyService, config {provider, model}, provides ctx.apiProxy, transport-agnostic and registers no routes — createApiProxy moved here from the retired runtime package. dsh-host-webserver shrank to a plain route-registration plugin: HttpServerService provides ctx.httpServer (register(route) → disposer with duplicate-pattern throw, tapIndex transforms applied in registration order, port), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers toFetchHandler(ctx.apiProxy) under the /api prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (ClientModuleHostService, providing ctx.clientModuleHost) owns the graph: incremental per-package scanning (no full-rescan code path — internal/plugin marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through rebuilt(id)), the bundle route, the index tap, and onRebuilt/onGraphChanged notification. The hmr node half owns dev reload: fs.watchFile stat-polling driven by onGraphChanged membership, and the /plugins/events SSE route.

Package export discipline. The modules package exposes exactly . (node half) and ./client (the complete browser half: ClientModuleSystem, parseBootManifest, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to window.__DSH_MODULES__ before cordis exists; the ./client apply reads the slot (missing = loud throw) and provides ctx.modules.

Consequences

  • Recomposing a web deployment is a yml/patch edit; the retired pieces (mountWebPlugins, CLIENT_PACKAGES, createHostWebPluginRegistry, startWebServer, the webserver's graph/SSE/api knowledge) are deleted.
  • Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains ask_user_question/workspace context/model titles per the unification ruling, and bootHost/startHost retired with the dsh-host-runtime package. The profile write path, the $DSH_HOME profile relocation, and IPC carriers remain recorded deferrals.
  • A TypeScript pitfall worth remembering: a declare module 'cordis' augmentation in a file with no cordis import is demoted to a standalone module declaration and silently shatters the program-wide Context merge (ctx.on/ctx.effect vanish across the program). Anchor with import type {} from 'cordis'.

Alternatives considered

Rejected One-line reason
Dedicated dsh-host-profile receiver package The profile json is consumed at patch time; the only runtime consumer of {provider, model} is the gateway itself — its config is the receiver
Runtime assembly shim plugin providing an apiHandler service Existed only because createApiProxy lived in runtime; moving it into apiproxy made the gateway self-hosting, and toFetchHandler is a pure function the binding side calls
Full-rescan + incremental scan coexisting Two implementations, two semantics; the single per-package path covers the activation pass too
A bespoke ./impl export on the modules package Non-uniform export surface; the standard ./client carries the whole browser half
dev overlay / cordis.dev.yml One yml; !!js cannot conditionalize row existence, and --dev appending one row is the entire difference
env vars in the mapping table The same field would gain env/json double sourcing and need an invented precedence
Unbarriered create-after-prefetch (arrive() dedup as safety) Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges
json file used directly as loader patches json keys would couple to yml row structure; profile writers would need cordis knowledge