Merge remote-tracking branch 'origin/master' into worktree/locale-browser-default

# Conflicts:
#	apps/web/tests/onboarding-deepseek-config.e2e.ts
#	packages/client/ui-settings-general/tests/apply.spec.ts
This commit is contained in:
creatixchu
2026-07-31 16:42:37 +08:00
136 changed files with 3428 additions and 451 deletions

View File

@@ -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 .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
2026-07-30-deepseek-onboarding-credential-setup.md: 571b81a1a2e6f392f2553070048964d49941aae9
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 744c30814f84d063f196ce20ba48fb993d0b7713
2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247

View File

@@ -12,11 +12,11 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract.
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret.
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability suppresses the modal because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later dismisses a missing-credential overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload.
## Alternatives considered
@@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
## Consequences
The first-run flow leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.

View File

@@ -10,13 +10,13 @@ Status: implemented
## 决策
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store`llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 所有、设置路径为空`deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同一提供方 ID 下的存活路由若没有匹配可配置提供方声明首次使用引导会将其视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store`llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有`deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()`私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
**不可用状态不会拦截产品交互。**可配置提供方条目缺失、路由未激活、初始联接失败、部署只读设置能力无法解析或凭据能力无法解析时均不显示模态框,因为首次使用引导的操作无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会在当前已挂载界面中关闭凭据缺失浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层
**不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读设置凭据能力无法解析时,都会直接完成而不渲染该步骤因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤
## 曾考虑的替代方案
@@ -30,4 +30,4 @@ Status: implemented
## 后果
首次使用流程无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放链路还固化了同一提供方 ID 下的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe
2026-07-30-web-read-card-frontend.zh.md: b6314f21ba3eb2283788374b10c77ed22e26d16c

View File

@@ -0,0 +1,54 @@
# Agent Note: Web read card frontend — the read tool's line window renders line-numbered and highlighted
Status: implemented
English | [中文](2026-07-30-web-read-card-frontend.zh.md)
## Problem
The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent card, `card: 'read'`, to `ToolResultView`: a settled read now carries `{ path, lines: [{ number, text }], totalLines, lang? }` onto the conversation snapshot as `resultView`. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content blocks into one `<pre>`, so a read showed as `N: text`-prefixed plain text with no gutter, no syntax highlighting, and no "showing N of M" affordance for a windowed read. The [web terminal card](2026-07-28-web-terminal-card.md) established the pattern for consuming a structured card; the read card follows it, result-side only.
## Decision
`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
**A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `<pre>` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`<pre>` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
**Read-card grammars load lazily; only the boot three stay eager.** `highlight.ts` is a platform seed `ui-primitives` loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's `langFromPath` hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and `run_code` languages) — load at boot. Each read-card extension grammar sits behind a dynamic `import()` in `LAZY_GRAMMARS`, keyed by the grammar id its aliases resolve to. On the first `highlightLines`/`highlightToHtml` call for a lazy language, `ensureGrammar` starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with `loadLanguageSync`, bumps a load counter, and notifies subscribers. `ReadBlock` and `CodeBlock` subscribe through `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)`, so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).
**The empty-window copy control is hidden, matching `TerminalBlock`.** A successful read of an empty file returns `lines: []`, `totalLines: 0`, and `presentResult` still projects `card: 'read'`, so the empty-window branch is reachable — the read card is not, as an earlier draft assumed, unreachable for an empty result. `ReadBlock` therefore hides the copy control when `lines` is empty, exactly as `TerminalBlock` hides copy on empty output, so the button can never wipe the clipboard with an empty string.
## Alternatives considered
**Extend `CodeBlock` with an optional line-number gutter and `startLine`.** Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and `run_code` body that shares `CodeBlock`, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through `highlight.ts`; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as `TerminalBlock` is a second primitive over the same tokens rather than a `CodeBlock` mode.
**Reuse `highlightToHtml` and inject gutter numbers with CSS counters.** Rejected: the single-`<pre>` HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. `codeToTokens` gives the per-line token structure directly.
**Register all read-card grammars eagerly in the boot warm-up.** Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.
## Consequences
`ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged.
A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form.
## Testing
`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
## Related
- [Read card backend](2026-07-30-web-read-card.md) — adds the `card: 'read'` result view this consumes; produces the `lines`/`totalLines`/`lang` this renders.
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this follows: a `ui-primitives` block, a `contract/*-card-model.ts` derivation, a keyed row, and making `GenericToolCard`/`DetailsPanel` card-aware.
- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and the shiki `highlight.ts` singleton this extends with a per-line token path.
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `read` arm.

View File

@@ -0,0 +1,54 @@
# Agent Note: Web 读取卡片前端 —— 读取工具的行窗口以带行号、语法高亮的形式渲染
Status: implemented
[English](2026-07-30-web-read-card-frontend.md) | 中文
## Problem
[读取后端](2026-07-30-web-read-card.md)给 `ToolResultView` 增加了第四种渲染意图卡片 `card: 'read'`:一次已结算的读取现在会把 `{ path, lines: [{ number, text }], totalLines, lang? }` 作为 `resultView` 带到会话快照上。这份数据能到达浏览器,但 Web 客户端没有消费者。每个读取行都仅从参数派生,详情面板把结果的 content block 摊平进一个 `<pre>`,于是读取显示为带 `N: text` 前缀的纯文本,没有行号栏、没有语法高亮,也没有窗口读取的"显示 N / M"提示。[web 终端卡片](2026-07-28-web-terminal-card.md)确立了消费一个结构化卡片的模式;读取卡片沿用它,只在结果侧。
## Decision
`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
**新建一个 `ReadBlock` primitive而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `<pre>` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `<pre>` HTML于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock``ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView``kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`8是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序接缝为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限16渲染同一张卡片因为面板是单次调用的阅读界面。
整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
**读取卡片的语法按需 lazy 加载,只有 boot 三种保持 eager。** `highlight.ts``ui-primitives` 在每次 Web 启动都加载的平台 seed其预热会无条件构建 shiki 单例。读取卡片的 `langFromPath` 提示覆盖完整的源码/配置/标记扩展集python、rust、yaml、html……把它们全部 eager 注册会给启动 chunk 增加约 1.6 MB 的语法模块、并把它们的同步初始化摊给每个会话,包括从不打开读取卡片的会话。因此只有每个会话本就渲染的三种语法 —— TypeScript、shell、JSONmarkdown 围栏与 `run_code` 语言)—— 在 boot 时加载。每种读取卡片扩展语法置于 `LAZY_GRAMMARS` 中一个动态 `import()` 之后,以其别名解析到的语法 id 为键。对某个 lazy 语言首次调用 `highlightLines`/`highlightToHtml` 时,`ensureGrammar` 启动 import仅一次并返回未就绪于是卡片该帧渲染纯文本import 解析后用 `loadLanguageSync` 注册该语法、递增一个加载计数、并通知订阅者。`ReadBlock``CodeBlock` 通过 `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)` 订阅,因此语法就绪的那一刻卡片就重渲染带上高亮。未知/缺省语言仍同步返回 undefined纯文本绝不报错
**空窗口的复制控件被隐藏,与 `TerminalBlock` 对齐。** 成功读取一个空文件会返回 `lines: []``totalLines: 0`,且 `presentResult` 仍投出 `card: 'read'`,因此空窗口分支是可达的 —— 读取卡片并非如早前草稿所假设的对空结果不可达。故 `ReadBlock``lines` 为空时隐藏复制控件,正如 `TerminalBlock` 对空输出隐藏复制,使按钮绝不会用空字符串清空剪贴板。
## Alternatives considered
**给 `CodeBlock` 加一个可选行号栏和 `startLine`。** 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 `CodeBlock` 的每个 markdown 围栏和 `run_code` 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 `highlight.ts` 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 `TerminalBlock` 是基于同一套 token 的第二个 primitive而不是 `CodeBlock` 的一种模式。
**复用 `highlightToHtml`,用 CSS counter 注入行号。** 拒绝shiki 产出的单 `<pre>` HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。`codeToTokens` 直接给出逐行 token 结构。
**在 boot 预热里 eager 注册所有读取卡片语法。** 拒绝:这会给每次 Web 启动摊上约 1.6 MB 语法模块及其同步初始化只为一张多数会话从不打开的卡片。lazy 路径的代价是某个语言首次被读取时的一帧纯文本随后在语法加载的重渲染里高亮boot 代价只为每个会话本就渲染的三种语法付出。
## Consequences
`ui-primitives` 增加 `ReadBlock``highlightLines`没有新的运行时依赖shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。
Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。`run_code` 子派发在已发布的线路上到不了读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts``tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。
## Testing
`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径lazy 语法首次触碰返回纯文本import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx``highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
fixture`packages/client/connection/src/client/fixture.ts`)增加 turn 66一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx``web_fetch` 用例钉住。turn 66 排在 todo turn现为 67之前与终端样例同因常驻计划在下一次 `turn/start` 退场。
## Related
- [读取卡片后端](2026-07-30-web-read-card.md) —— 增加本文消费的 `card: 'read'` 结果视图;产出本文渲染的 `lines`/`totalLines`/`lang`
- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本文遵循的先例:一个 `ui-primitives` block、一个 `contract/*-card-model.ts` 派生、一个 keyed 行,以及让 `GenericToolCard`/`DetailsPanel` 感知卡片。
- [Web 客户端语法高亮](../process/2026-07-26-web-syntax-highlighting-shiki.md) —— 拥有 `CodeBlock` 与 shiki `highlight.ts` 单例,本文以逐行 token 路径扩展它。
- [工具调用呈现的标签式渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇表Web 客户端现在是 `read` 分支的完整消费者。

View File

@@ -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 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
2026-07-28-storage-root-and-derived-medium-recovery.md: 7759d0b6a8d522d6f853be0cd31a0b3909292446
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 893b91c2ad3943cce9f64702e52afbb8075b907c

View File

@@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
**Where the files actually live.** The shipped Web overlay gives the json backend a relative root `root: './.storages'` (`apps/cli/config/web.cordis.yml`)while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
**Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `<launch dir>/.storages/`two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts).
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
@@ -16,11 +16,11 @@ The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-
Two independent changes, one per gap.
### One global storage root, resolved once
### One global storage root (shipped, amended form); resolved once at construction (still open)
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')``~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row, with the same `!!js` IIFE the session root uses (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The user ruled this form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The verbatim duplication of the expression against the session root (`base.cordis.yml`) is a known cost — acceptable at two consumers; a third `$DSH_HOME`-derived root triggers extracting a single source (a launcher variable or a shared expression). The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home.
- **Still open**: `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
- Pre-release stance applies (and was executed): no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
### Declared derived media: reset instead of reject
@@ -31,7 +31,9 @@ Two independent changes, one per gap.
## Alternatives considered
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
**Keep per-launch-directory `.storages` (the pre-change status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
**Launcher patch + a `storageRoot` profile key (this section's original form)** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
@@ -45,7 +47,7 @@ Two independent changes, one per gap.
## Acceptance criteria
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
- The same damage to `workspace.json` still fails boot loudly.
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.

View File

@@ -8,7 +8,7 @@ Status: proposed
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
**文件到底存在哪** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'``apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions``storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()`packages/storage/storage-json/src/index.ts——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患"later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts净效果:会话日志跨启动目录全局共享,但 `workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
**文件到底存在哪根错位已收口resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 给 json 后端相对根 `./.storages``workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages``apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()`packages/storage/storage-json/src/index.ts;出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它"later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit``malformed-medium`/`version-mismatch` 失败packages/storage/storage-json/src/format.tsschema 漂移的记录让域 open 以 `invalid-record` 失败packages/storage/storage-domain/src/index.ts拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc"version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
@@ -16,11 +16,11 @@ Status: proposed
两个独立改动,一个缺口一个。
### 全局唯一存储根构造时 resolve 一次
### 全局唯一存储根(已落地,形态修正);构造时 resolve 一次(仍开放)
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json``root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
- **已落地**:出厂 Web overlay 直接在 `storage-json` 行内用与会话根同一段 `!!js` IIFE 把 `root` 锚定到 `$DSH_HOME/storages`默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩目录名不带点——home 本身已是隐藏树。用户拍板采用此形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。该表达式与 sessions 根(`base.cordis.yml`)逐字重复是已知代价——两个消费者尚可接受;出现第三个 `$DSH_HOME` 派生根时提取单一来源launcher 变量或共享表达式。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home
- **仍开放**`JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场(已按此执行):不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
### 声明派生介质:损坏时重置而非拒绝
@@ -31,7 +31,9 @@ Status: proposed
## Alternatives considered
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**保持按启动目录的 `.storages`改动前现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**launcher patch + `storageRoot` profile 键(本节初版提案形态)**——未采:一行 yml `!!js` 表达式即达全局根与会话根的既有分层完全一致launcher patch 多引入一个改写点profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
@@ -45,7 +47,7 @@ Status: proposed
## Acceptance criteria
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。