fix(windows): close native coverage review gaps

This commit is contained in:
Tianyi Cui
2026-08-09 13:59:49 +08:00
parent 02ea70cce0
commit 03fafca0e0
15 changed files with 111 additions and 120 deletions
+2 -2
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 packages/skill/skill-local/README.md
README.md: dc2e97f89349a85ce548e5f6f1b01408eb32a293
README.zh.md: 0c17d9fb77af3df2071613c2c6b8fc15086581a7
README.md: 26ce8ed628c4bdcb3a79de204bfae2668067ec0d
README.zh.md: c741d120160755675c2c760d7ccf6d72532f420c
+1 -1
View File
@@ -44,7 +44,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
## Catalog Change Detection
Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; discovery and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
+1 -1
View File
@@ -44,7 +44,7 @@
## 目录变更检测
现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;`watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name``description` 等目录 frontmatter。`references``scripts``assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents``skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
+10 -10
View File
@@ -9,7 +9,7 @@
* @module @deepseek-ai/dsh-skill-local
*/
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { access, lstat, readdir, readFile, stat } from 'node:fs/promises'
import { unwatchFile, watchFile, type Stats } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { homedir } from 'node:os'
@@ -394,10 +394,9 @@ class SkillWatchManager {
private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {
const watcher = state.watcher
if (watcher !== undefined && !state.unhealthy) {
const current = await resolveRootWatchMode(state.root.path)
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
// A child unlink can publish an empty catalog before root unlinkDir arrives.
// Discovery therefore revalidates the retained handle independently.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return
}
await this.replaceWatcher(state)
@@ -414,7 +413,6 @@ class SkillWatchManager {
/* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */
if (watcher === undefined) return
/* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
if (this.closing || state.owners.size === 0) {
await this.closeWatcher(watcher)
return
@@ -423,7 +421,6 @@ class SkillWatchManager {
state.watcher = watcher
state.unhealthy = false
} catch (error) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
if (!this.closing) {
state.unhealthy = true
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
@@ -436,11 +433,11 @@ class SkillWatchManager {
// service; keep skill filtering and invalidation here.
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
while (!this.closing && state.owners.size > 0) {
const mode = await resolveRootWatchMode(state.root.path)
const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
const watcher = mode.kind === 'ancestor'
? this.openAncestorWatcher(state, mode)
: await this.openRootWatcher(state, mode)
const current = await resolveRootWatchMode(state.root.path)
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
/* v8 ignore else -- A host path transition between the two probes is timing-dependent. */
if (sameWatchMode(mode, current)) return watcher
/* v8 ignore next -- Covered by the same host path transition guard. */
@@ -472,7 +469,7 @@ class SkillWatchManager {
): Promise<void> {
let current: RootWatchMode
try {
current = await resolveRootWatchMode(state.root.path)
current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
} catch (error) {
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
@@ -623,13 +620,16 @@ function resolveWatchConfig(config: Config): ResolvedWatchConfig {
}
}
async function resolveRootWatchMode(root: string): Promise<RootWatchMode> {
async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise<RootWatchMode> {
let candidate = root
while (true) {
try {
const info = await stat(candidate)
if (info.isDirectory()) {
const anchor = await canonicalizeWatchPath(candidate)
const preserveRootLink = candidate === root
&& !followSymlinks
&& (await lstat(candidate)).isSymbolicLink()
const anchor = preserveRootLink ? resolve(candidate) : await canonicalizeWatchPath(candidate)
if (candidate === root) return { kind: 'root', anchor }
const firstSegment = relative(candidate, root).split(sep)[0]
/* v8 ignore next -- candidate is a strict ancestor of root. */
@@ -136,6 +136,32 @@ describe('skill-local watcher failures', () => {
await fiber.dispose()
})
it('preserves a symlink root when link following is disabled', async () => {
const target = await tempDir('skill-watch-link-target')
const aliasParent = await tempDir('skill-watch-link-alias')
const alias = join(aliasParent, 'skills')
await writeSkill(target, 'linked-skill')
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
includeDefaultRoots: false,
customSkillDirs: [alias],
watch: true,
watchFollowSymlinks: false,
})
try {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-skill'])
expect(watcherHarness.watchers[0]?.path).toBe(alias)
expect(watcherHarness.watchers[0]?.options.followSymlinks).toBe(false)
} finally {
await fiber.dispose()
await rm(aliasParent, { recursive: true, force: true })
await rm(target, { recursive: true, force: true })
}
})
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
const ctx = new Context()