From 702e2d024a0f6957c785b6f597a28ee53da458aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:29:43 +0800 Subject: [PATCH] fix(windows): canonicalize native watch paths --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 4 ++ ...08-08-native-windows-pull-request-ci.zh.md | 4 ++ .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 2 +- .../credentials-local/src/index.ts | 4 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 4 +- packages/ui/app-boot/tests/hmr-config.spec.ts | 46 +++++++++++++++++-- packages/util/paths/README.i18n.yaml | 4 +- packages/util/paths/README.md | 6 ++- packages/util/paths/README.zh.md | 6 ++- packages/util/paths/src/index.ts | 30 +++++++++++- packages/util/paths/tests/paths.spec.ts | 22 ++++++++- scripts/project-doc-site.spec.ts | 6 +-- vendor/README.md | 2 +- vendor/hmr/README.md | 6 +++ vendor/hmr/src/index.ts | 31 ++++++++----- 21 files changed, 157 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 3b3d2dc1dd..c9d06abacf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 926eec04cff63aaf25d1e85b3dcbc41464f25660 -2026-08-08-native-windows-pull-request-ci.zh.md: 8728f0eb08adcb1501f036bb19ff523ded406ccc +2026-08-08-native-windows-pull-request-ci.md: 8b6766cfa8df48c6c61410c06a410340a0c9bb21 +2026-08-08-native-windows-pull-request-ci.zh.md: 29cb1f725f4212c13974d642388edaf1df16b759 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 926eec04cf..8b6766cfa8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,6 +14,8 @@ The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) run The aggregate keeps workspace build and production-site failures blocking while reporting the broader static, documentation, package, and built-artifact portability inventory as observational. One runner shares installation and build outputs across those gates, and serial gate and publint worker bounds keep the standard image within a predictable resource envelope. Linux remains the owner of duplicate lint, coverage, and snapshot enforcement until those suites have an explicit native-Windows contract. +The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. + Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path. ## Alternatives considered @@ -30,4 +32,6 @@ Wine-only infrastructure is absent from the supported workflow: there is no apt- Pull requests receive a real NT kernel, NTFS, PowerShell, Windows process, and native addon signal before the aggregate can pass. The job is slower than the Wine compatibility lane and can queue on Windows capacity, but its green result now describes the supported host rather than an approximation. +The native lane is also a portability inventory: its exact-head acceptance requires every blocking gate to pass and the final summary to contain no non-blocking failure. That distinction caught path contracts which a successful wrapper conclusion alone would have concealed. + Removing the Wine cache producer and local script deletes a separate install topology and its recurring compatibility failures. Native coverage and snapshots remain a named gap rather than being implied by the job name; they require their own tested contract before becoming part of this required lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 8728f0eb08..29cb1f725f 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,6 +14,8 @@ Status: implemented 聚合作业继续将工作区构建与生产网站故障设为阻断项,同时将更广泛的静态检查、文档、包和构建产物可移植性清单作为观测项报告。同一台运行器在这些门禁之间共享安装结果与构建输出,串行门禁与 publint 工作线程上限使标准镜像的资源使用保持在可预测范围内。在这些套件明确建立原生 Windows 契约之前,重复执行的 lint、覆盖率与快照强制检查仍由 Linux 负责。 +首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。 + 受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。 ## 曾考虑的替代方案 @@ -30,4 +32,6 @@ Status: implemented 在聚合作业通过之前,拉取请求会获得来自真实 NT 内核、NTFS、PowerShell、Windows 进程和原生插件的信号。该作业比 Wine 兼容性通道更慢,也可能因 Windows 容量而排队,但其绿灯结果描述的是受支持的宿主,而非近似环境。 +原生通道也是一份可移植性清单:其分支头精确验收要求每个阻断门禁都通过,且最终汇总中不包含任何非阻断失败。这一区分发现了路径契约问题;若只看包装层的成功结论,这些问题本会被掩盖。 + 移除 Wine 缓存生产者和本地脚本后,独立的安装拓扑及其反复出现的兼容性故障也随之消失。原生 Windows 覆盖率与快照仍是明确列出的缺口,不会仅由作业名称暗示已经纳入;二者都必须先建立各自经过测试的契约,才能成为该必需通道的一部分。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 07a3efd5c3..66ad77f648 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 8e95a890a8e38172cf8984653a01c59570f0061a -README.zh.md: 04ad07ae4e703ab0416d1d8f1bb6a6ff90adf337 +README.md: 035d558d8cbd51ce46af72d7bd9cd56bed85ebcd +README.zh.md: 11c772951dab3ce0cea3a13e739198d41028d598 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 8e95a890a8..035d558d8c 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -47,7 +47,7 @@ The provider creates the directory `0700` and creates or atomically replaces the ## Hot reload -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud. ## Security boundary diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 04ad07ae4e..11c772951d 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -47,7 +47,7 @@ OPENAI_API_KEY: sk-… ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 ## 安全边界 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index e781c10f3e..67b598d7e0 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -41,7 +41,7 @@ import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Document, parseDocument, type YAMLError } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { environmentOf } from '@deepseek-ai/dsh-environment' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -270,7 +270,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- same watcher discipline as settings-local by design: the serialized-refresh and quiesce-on-dispose shape is the reviewed lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { + const watcher = chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index ec26e4241f..188e4361bb 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/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/settings/settings-local/README.md -README.md: d1f3d755f9073acdf6fcfc5d1de883d74cc023c4 -README.zh.md: 03c7ef7c35312d9e1e4a86b0df71412875af7d6c +README.md: a0bc94630f78aa7c101e3eb0795a7d07e585e347 +README.zh.md: 2d2d0828f2eadde9867ab91fe8fbd4c61dea4c11 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index d1f3d755f9..a0bc94630f 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -24,6 +24,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap. +- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv. - **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal. - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op. - **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 03c7ef7c35..2d2d0828f2 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -24,6 +24,7 @@ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。 +- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。 - **Dispose 在每种 watch 模式下都保证静止。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。 - **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。 - **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 0ce07af40c..16bc641f05 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' /** Plugin config: file location and hot-reload behavior. */ @@ -235,7 +235,7 @@ export class SettingsLocal extends Settings { // silently ignored or overwritten. yield* super[Service.init]() const watcher = this.spec.watch - ? chokidarWatch(this.spec.filename, { + ? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: this.spec.debounceMs, diff --git a/packages/ui/app-boot/tests/hmr-config.spec.ts b/packages/ui/app-boot/tests/hmr-config.spec.ts index 1892a6e73a..fe6bb556b4 100644 --- a/packages/ui/app-boot/tests/hmr-config.spec.ts +++ b/packages/ui/app-boot/tests/hmr-config.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -8,12 +8,12 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { describe, expect, it } from 'vitest' -async function bootHmr(dir: string): Promise { +async function bootHmr(dir: string, root: string[] = []): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dir).href + '/' await ctx.plugin(Loader) await ctx.plugin(Timer) - await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await ctx.plugin(Hmr, { root, ignored: [], debounce: 0 }) return ctx } @@ -26,6 +26,46 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + it('observes module changes when its watch base is a filesystem alias', { timeout: 20_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const filename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(filename, 'export const generation = 0\n') + const ctx = await bootHmr(alias, ['.']) + const expected = pathToFileURL(filename).href + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 10_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) throw new Error('HMR did not observe a module change through the alias') + writeFileSync(filename, `export const generation = ${generation}\n`) + await new Promise(resolve => setTimeout(resolve, 20)) + } + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await ctx.hmr.registerConfig('plugins.yml', () => {}) + await expect(ctx.hmr.registerConfig(join(realpathSync(target), 'plugins.yml'), () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + rmSync(alias, { force: true }) + rmSync(target, { recursive: true, force: true }) + } + }) + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) const filename = join(dir, 'plugins.yml') diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index f79f6b2d73..6bed54f5c3 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/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/util/paths/README.md -README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c -README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd +README.md: 8d10ed855a37f1205f87420b3f36f45b10e65bd3 +README.zh.md: ed3ca377bd48252fe0ef3f95186dc6eb1fb6e6a0 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 2b3272e019..8d10ed855a 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -18,9 +18,13 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Watch paths + +`canonicalizeWatchPath()` gives a native filesystem watcher one stable spelling of its target. It resolves the deepest existing ancestor through `fs.realpath()` and restores any missing suffix, so a file or directory may still be watched before it is created. In particular, Windows 8.3 aliases cannot be mixed with the long paths emitted by the native watcher backend. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work - **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged. -- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path. +- **Canonicalization reads but never mutates** — `canonicalizeWatchPath()` performs `realpath` probes and propagates errors other than absence; callers still own directory creation, permissions, and trust policy for the resulting path. diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index 7fe0ec0411..ed3ca377bd 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -18,9 +18,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 +## 监听路径 + +`canonicalizeWatchPath()` 为原生文件系统 watcher 提供一种稳定的目标路径表示。它通过 `fs.realpath()` 解析层级最深的现有祖先路径,再拼回缺失的后缀,因此即使文件或目录尚未创建也仍可监听。尤其是,Windows 8.3 别名不能与原生 watcher 后端发出的长路径混用。 + 该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 ## 已知限制与暂缓事项 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 -- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 +- **规范化会读取,但绝不修改**:`canonicalizeWatchPath()` 会执行 `realpath` 探测,并传播除路径不存在以外的错误;调用方仍负责目录创建、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index a8f9245c72..591158d7c0 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -4,8 +4,9 @@ * @module @deepseek-ai/dsh-paths */ +import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' /** Directory name for the default DeepSeek Harness home under the OS home. */ export const DSH_HOME_DIR_NAME = '.dsh' @@ -16,6 +17,33 @@ export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` /** Environment variable that overrides the default DeepSeek Harness home. */ export const DSH_HOME_ENV = 'DSH_HOME' +/** + * Give a native filesystem watcher one canonical spelling of a path, even + * when its final components do not exist yet. The deepest existing ancestor + * is resolved through {@link realpath}; the missing suffix is then restored. + * This prevents Windows short-name aliases from being mixed with long paths + * emitted by the native watcher backend. + * @param path - Watch target or root, resolved against the current directory. + * @returns the target with its existing ancestor canonicalized. + * @throws when ancestor traversal encounters an error other than absence. + */ +export async function canonicalizeWatchPath(path: string): Promise { + let current = resolve(path) + const missing: string[] = [] + while (true) { + try { + return join(await realpath(current), ...missing.reverse()) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(current) + /* v8 ignore next -- a filesystem root exists, so traversal resolves before this guard */ + if (parent === current) throw error + missing.push(basename(current)) + current = parent + } + } +} + /** * Resolve the default DeepSeek Harness home using Node's platform path rules. * @returns the absolute default harness home path. diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 08336bbcce..df5555f910 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,9 +1,11 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + canonicalizeWatchPath, defaultDshHome, dshHomeDisplay, dshHomePath, @@ -53,4 +55,22 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) + const target = join(root, 'target') + const alias = join(root, 'alias') + try { + await mkdir(target) + await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( + join(await realpath(target), 'later', 'config.yml'), + ) + const file = join(root, 'file') + await writeFile(file, 'not a directory') + await expect(canonicalizeWatchPath(join(file, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 89e417558c..3b39b83897 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' import { @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = basename(absPath) placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${basename(absPath)}`, })).toBe('![logo](./logo.svg#view)\n') }) diff --git a/vendor/README.md b/vendor/README.md index 4bf1d43f46..0a434605ae 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. +9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index db5ac1a49a..eec6ff940f 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -7,6 +7,12 @@ module caches, and reloads only the plugin entries that depend on changed application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. +Module watches canonicalize their existing base directory before opening +Chokidar. Exact config watches likewise canonicalize the deepest existing +ancestor, then restore any missing suffix. Callbacks and diagnostics retain the +requested absolute filename, while the native backend receives one filesystem +spelling even when Windows supplied an 8.3 alias. + ## Requirements - `@cordisjs/plugin-loader` diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 00864cd865..c1d379e36c 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -4,7 +4,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plug import type { Include } from '@cordisjs/plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' -import { stat } from 'node:fs/promises' +import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -61,13 +61,18 @@ interface ConfigRegistration { watcher: FSWatcher } -async function findWatchRoot(filename: string): Promise<{ root: string; depth: number }> { +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { let root = dirname(filename) let depth = 0 while (true) { try { if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - return { root, depth } + const canonicalRoot = await realpath(root) + return { + filename: resolve(canonicalRoot, relative(root, filename)), + root: canonicalRoot, + depth, + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error const parent = dirname(root) @@ -129,9 +134,11 @@ class Hmr extends Service { async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { if (!this.watcher) throw new Error('HMR is not active') filename = resolve(this.baseDir, filename) - if (this.configs.has(filename)) throw new Error(`config path already registered: ${filename}`) + const target = await findWatchRoot(filename) + const watchFilename = target.filename + if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - const { root, depth } = await findWatchRoot(filename) + const { root, depth } = target const watcher = watch(root, { ...this.config, cwd: undefined, @@ -140,9 +147,10 @@ class Hmr extends Service { ignoreInitial: false, }) const registration = { watcher } - this.configs.set(filename, registration) + this.configs.set(watchFilename, registration) const onChange = (path: string) => { - if (resolve(path) !== filename) return + const observed = resolve(path) + if (observed !== filename && observed !== watchFilename) return this.refreshConfig(registration, filename, refresh) } watcher.on('add', onChange) @@ -167,12 +175,12 @@ class Hmr extends Service { try { await ready.promise return this.ctx.effect(() => async () => { - if (this.configs.get(filename) === registration) this.configs.delete(filename) + if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) await watcher.close() await this.configRefreshes.get(registration)?.running }, 'hmr.registerConfig()') } catch (error) { - this.configs.delete(filename) + this.configs.delete(watchFilename) await watcher.close() throw error } @@ -205,10 +213,11 @@ class Hmr extends Service { } const match = picomatch(ignored) + const watchBaseDir = await realpath(this.baseDir) this.watcher = watch(root, { ...this.config, - cwd: this.baseDir, - ignored: path => match(relative(this.baseDir, path)), + cwd: watchBaseDir, + ignored: path => match(relative(watchBaseDir, path)), // The initial scan re-announces files the boot just consumed: an `add` // for a config file refreshes an include whose initial apply may still // be in flight, and a failing apply then rolls this plugin back while