From 1cce623b57eeddc58b4c34dffc8236cd5f1a99f6 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:43:25 +0800 Subject: [PATCH] feat(dsh): persist source-isolated local favorites Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../changes/add-dsh-native-plugin/design.md | 4 + .../add-dsh-native-plugin/favorites-design.md | 33 ++++ .../specs/dsh-native-experience/spec.md | 18 +++ .../changes/add-dsh-native-plugin/tasks.md | 10 ++ plugins/dsh/bun.lock | 2 + plugins/dsh/package.json | 6 +- plugins/dsh/src/host/favorites-store.ts | 148 ++++++++++++++++++ plugins/dsh/src/host/index.ts | 39 +++++ plugins/dsh/src/host/types.ts | 11 ++ plugins/dsh/test/favorites-service.test.ts | 93 +++++++++++ plugins/dsh/test/favorites-store.test.ts | 134 ++++++++++++++++ plugins/dsh/test/package.test.ts | 8 +- 12 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/add-dsh-native-plugin/favorites-design.md create mode 100644 plugins/dsh/src/host/favorites-store.ts create mode 100644 plugins/dsh/test/favorites-service.test.ts create mode 100644 plugins/dsh/test/favorites-store.test.ts diff --git a/openspec/changes/add-dsh-native-plugin/design.md b/openspec/changes/add-dsh-native-plugin/design.md index 0d5576a57..7c54597ec 100644 --- a/openspec/changes/add-dsh-native-plugin/design.md +++ b/openspec/changes/add-dsh-native-plugin/design.md @@ -241,3 +241,7 @@ flowchart TB ## 版本目录的开源接口增量 排序、可安装状态过滤和分页总数由开源后端统一计算;DSH 每页请求一次 `downloadableOnly=true`,不全量拉取或逐项 resolve 探测。固定版本安装仍经既有目标、指纹与包校验,管理者缺省历史行为保留。完整参数和排序契约见 [Registry API](registry-api-contract.md#版本目录的开源接口增量);SaaS 同步单独验收。 + +### 本地收藏 + +Issue #1064 的独立用户偏好、来源身份、轻量书签交互、原子写受理边界与验收见 [本地收藏设计](favorites-design.md)。该功能不改变上述Registry权威、匿名消费、库存v2或技能文件安装事务边界。 diff --git a/openspec/changes/add-dsh-native-plugin/favorites-design.md b/openspec/changes/add-dsh-native-plugin/favorites-design.md new file mode 100644 index 000000000..856707d2b --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/favorites-design.md @@ -0,0 +1,33 @@ +# 本地收藏与当前来源筛选(Issue #1064) + +## 目标与交互 + +帮助用户保存稍后查看的市场技能,不要求当场安装。沿用现有 shadcn/Radix 适配组件:市场卡片标题行、市场详情标题操作区各一个小书签按钮,明确 aria-label/title/aria-pressed,点击不穿透至详情;不增加一排文字操作。浏览技能页提供“仅看收藏”切换,读取当前来源的完整收藏列表并本地搜索,不能只筛选已加载的市场分页。空态简洁,错误可重试。收藏不代表安装或服务端收藏数,不增加登录、置顶、最近使用或统计。 + +收藏卡不展示快照版本为最新。用户点击该项详情或安装时,仅解析这一项当前可下载版本,再走既有精确版本详情和安装确认;不批量后台解析收藏、不静默装旧快照。离线或下架时保留收藏,仍可取消。详情的官网链接保留。 + +## 存储与边界 + +存储在 canonical DSH_HOME 下 `skillhub/favorites.json`,独立于inventory v2与设置草稿;相同DSH_HOME的Profile/项目共享,不提供收藏安装范围选择,不同DSH_HOME隔离。身份为规范化Registry来源+namespace+slug,名称只是快照,版本不进入身份。切源只显示新来源收藏,保留旧来源记录。 + +文件schema为 `{version:1,items:[{registry,namespace,slug,displayName?,summary?}]}`。不存凭证、安装令牌、任务或对话内容。坐标各最多256字符,展示名称256、摘要1024、规范化来源2048字符;全文件最多500项、8MiB。展示快照可在Client按长度截取,Host仍须校验。超限明确报错,不静默丢弃已有记录。未知schema、损坏JSON、非普通文件、数据目录内symlink拒绝覆盖,不自动重置;不存在文件视为空。 + +复用官方 `@deepseek-ai/dsh-atomic-write` 的withFileLock与writeFileAtomic,显式声明三个支持RC的peer和锁定开发SDK,不借用未声明传递依赖。目录新建0700、文件0600;完整读→校验→合并→原子写在同一个跨进程锁中,等待最多2000ms。三个RC共同使用waitMs参数;旧RC2不承诺等锁时立即取消,拿锁后、原子写受理前重新核验来源revision和取消;开始调用writeFileAtomic即受理,受理后切源允许完成原来源的写入,不补写新来源,迟到结果不进入新源UI。相同明确目标状态幂等,不使用反转式toggle。不同技能并发收藏不丢更新;共享文件仅支持同机同PID命名空间,不承诺网络盘跨主机写入。 + +收藏依赖/存储失败仅影响收藏,不扩大现有安装库存写门禁;不触碰技能文件、CLI共享库存或服务端收藏API。读取也校验文件类型与大小;错误只返回稳定代码,不透出路径或原始文件内容。 + +## Host RPC与Client状态 + +- `listFavorites(sourceRevision,signal): FavoriteList`:返回当前规范化来源全部展示快照,不发Registry HTTP。 +- `setFavorite(sourceRevision,item:FavoriteSkill,favorite:boolean,signal): FavoriteList`:以当前来源为准保存明确状态,锁内重验来源/取消,成功后返回该来源列表,不发Registry HTTP。 +- `resolveFavoriteVersion(sourceRevision,namespace,slug,signal): FavoriteVersion`:只读调用现有Registry.resolve省略version,返回坐标与明确版本,不返回下载URL/令牌;然后由既有详情重新核验,快照不授予安装权限。 + +复用readSource及生成Remote,三个方法是本机用户偏好/只读入口,不假冒agent范围文件安装权限。类型在host/types.ts维护,生成绑定不手改。存储错误统一 `skillhub/favorites-error`,reason为INVALID_INPUT、UNAVAILABLE、INVALID_STORE、LIMIT_REACHED;来源及Registry错误复用原契约。 + +Query缓存按sourceRevision隔离;来源切换后的旧查询、保存或解析结果不能进入新来源UI。成功持久化后再呈现保存成功;多次交互/多控件写入需避免返回旧全量列表覆盖新状态。保存失败保留原状态并提供重试,不能阻断市场浏览。收藏筛选期间不请求市场分页;切回后保留市场查询/排序/标签语义。 + +## 验收 + +正常:市场收藏→详情同态→完整收藏列表→重启读取→取消,不改变安装、库存或服务端starCount。变更:跨分页与本地搜索、两来源同名隔离、迟到响应/取消、最新版本解析、下架/离线保留、跨进程RMW、未知/损坏/超限/symlink不覆盖。以独立磁盘字节、HTTP记录、子进程屏障和真实DOM作为Oracle,不只mock返回值。 + +构建/类型/lint/测试、生成端点和打包检查;三个已支持RC至少实测收藏读写、来源隔离、重启和最新解析。RC5补浏览器完整流程、深浅主题与390px、键盘/焦点和卡片点击边界。CI、源码、真实Host、人工、合并与发布分开记录,不发送真实模型或发布npm。 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md index 040d3dde8..45635e2fe 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md @@ -646,3 +646,21 @@ The source candidate SHALL enumerate `0.2.0-rc.2`, `0.2.1-rc.2` and `0.2.1-rc.5` - **WHEN** RC5 support is evaluated for release - **THEN** Node 24.2.0 and current Node 24, native Host behavior, browser interaction and platform CI are recorded separately - **AND** a local Linux pass does not imply native Windows/macOS, model execution or published-package acceptance + +### Requirement: Source-isolated local skill favorites +The plugin MUST let users save and remove local skill favorites without login, installation, inventory changes, or a server-side star mutation. Identity MUST include the normalized Registry source, namespace and slug, but not a version. Favorites MUST survive Host restarts, remain shared across projects and Profiles using the same DSH_HOME, and remain isolated from other sources and DSH_HOME values. + +#### Scenario: Save and find a favorite independently of market pagination +- **GIVEN** a public skill appears on a later market page +- **WHEN** the user saves it from its card or detail and reopens the local favorites filter +- **THEN** the complete current-source favorites list includes it without loading those market pages, the two controls agree, and no skill installation or server star count changes + +#### Scenario: Open current content without locking an old snapshot +- **GIVEN** a saved skill has a newer downloadable release than when it was favorited +- **WHEN** the user opens that favorite +- **THEN** only that identity is resolved at the current source and the existing fixed-version detail/install flow is used; an offline or missing skill stays saved without silently installing the old snapshot + +#### Scenario: Preserve source and persistent-write boundaries +- **GIVEN** two sources contain the same coordinates or multiple local Hosts save different favorites +- **WHEN** the source changes or concurrent writes finish +- **THEN** old responses do not enter the new source view, each source retains its own favorites, atomic locked read-modify-write preserves both records, and damaged or unknown storage is not overwritten diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index ee4594a1f..36bf17e66 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -476,3 +476,13 @@ Chromium 145.0.7632.6连接真实RC5 Host与本地Registry夹具,验证目录/版本、用户级安装、项目级ZIP导入、案例取消和填入、新会话使用、原草稿文字与附件保留、390px及深浅主题,页面异常为0。原附件上传HTTP200且原生UI无上传中/失败状态。返回原空白会话由公开SDK验收驱动完成,原生侧栏隐藏空白会话的限制未解决;未发送模型消息。驱动不保活、不改写草稿,不进入发布包。 准备阶段首次官方npm下载超时,公共镜像与缓存重试后成功;首次复用同一HOME的installed-state探针因前序不同命名空间占用用户技能目标而被覆盖保护拒绝,改用逐探针独立HOME后全部通过,未因此放宽产品保护。CI19组合配置与制品匹配已校验,但远程CI、原生Windows/macOS、真实模型、SaaS及发布未执行。外部验收记录包含final-dsh-check.log、verified-installs.json、final-*-matrix.json、browser-acceptance.json、final-review.json和截图;临时预览在验收后关闭。 + +## 54. 本地收藏与当前来源筛选(#1064) + +- [x] 54.1 核对现有卡片、来源、生成Remote与官方持久化原语,明确最小交互、身份、快照和跨Host写入边界,设计见favorites-design.md。 +- [ ] 54.2 独立收藏store与Host RPC:来源隔离、完整读取、明确状态幂等写、官方锁/原子写、数据校验与稳定错误;不改inventory或既有安装门禁。 +- [ ] 54.3 复用卡片/详情书签和浏览页收藏筛选,完整本地搜索、最新版本打开、同步/迟到/失败状态与键盘焦点。 +- [ ] 54.4 以独立磁盘/HTTP/跨进程Oracle验证正常、边界、错误、并发和来源切换,执行构建、类型、lint、测试及包/生成契约检查。 +- [ ] 54.5 三RC真实Host偏好/RPC回归、RC5浏览器/重启/分页/主题/390px验收、独立评审及人工预览;发布后置。 + +第53章后续CI已在PR #1063完成:30项通过、7项条件跳过,包含19个DSH组合;用户人工确认后已合并,临时预览与模型进程已关闭。它不替代第54章新增功能验收。 diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock index 7d9d72d18..960847a22 100644 --- a/plugins/dsh/bun.lock +++ b/plugins/dsh/bun.lock @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.2", "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.2", "@deepseek-ai/dsh-app-boot": "0.2.1-rc.2", + "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.2", "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2", "@deepseek-ai/dsh-client-ui-chat": "0.2.1-rc.2", "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.2", @@ -62,6 +63,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "4.0.4 || 4.0.5", + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", "@deepseek-ai/dsh-fs": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 28fc1a3ea..0a0bd12b1 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -86,7 +86,8 @@ "@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", "@deepseek-ai/dsh-tools": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", - "@deepseek-ai/dsh-user-questions": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5" + "@deepseek-ai/dsh-user-questions": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5", + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2 || 0.2.1-rc.2 || 0.2.1-rc.5" }, "dependencies": { "@deepseek-ai/schemastery": "3.18.4", @@ -142,7 +143,8 @@ "@deepseek-ai/dsh-session-query": "0.2.1-rc.2", "@deepseek-ai/dsh-skill-filesystem": "0.2.1-rc.2", "@deepseek-ai/dsh-tools": "0.2.1-rc.2", - "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2" + "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2", + "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.2" }, "engines": { "node": ">=24.2.0 <25" diff --git a/plugins/dsh/src/host/favorites-store.ts b/plugins/dsh/src/host/favorites-store.ts new file mode 100644 index 000000000..896a93429 --- /dev/null +++ b/plugins/dsh/src/host/favorites-store.ts @@ -0,0 +1,148 @@ +import { lstat, mkdir, open, realpath } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { join } from 'node:path'; +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'; +import { z } from 'zod'; +import { RegistryAdapter } from './registry-adapter.js'; +import type { FavoriteList, FavoriteSkill } from './types.js'; + +export const favoriteLimits = { items: 500, bytes: 8 * 1024 * 1024 } as const; +const coordinate = z.string().min(1).max(256).refine(value => value !== '.' && value !== '..' + // eslint-disable-next-line no-control-regex -- Coordinates cannot contain transport control characters. + && !/[\u0000-\u001f\u007f]/u.test(value)); +const favoriteSchema = z.object({ + namespace: coordinate, slug: coordinate, + displayName: z.string().max(256).optional(), summary: z.string().max(1024).optional(), +}).strict(); +const storedFavoriteSchema = favoriteSchema.extend({ registry: z.string().min(1).max(2048) }); +const documentSchema = z.object({ version: z.literal(1), items: z.array(storedFavoriteSchema).max(favoriteLimits.items) }).strict(); +type StoredFavorite = z.infer; +type Document = z.infer; +type Reason = 'INVALID_INPUT' | 'UNAVAILABLE' | 'INVALID_STORE' | 'LIMIT_REACHED'; +export class FavoritesError extends Error { + constructor(readonly reason: Reason) { super(reason); this.name = 'FavoritesError'; } +} +class ContextChanged extends Error { + constructor(readonly original: unknown) { super('Favorites context changed'); } +} +function missing(error: unknown): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; +} +function sourceIdentity(source: string, reason: Reason): string { + try { + const normalized = new RegistryAdapter(source).source; + if (normalized.length > 2048) throw new Error('Too long'); + return normalized; + } catch { throw new FavoritesError(reason); } +} +export function validateFavoriteSkill(input: unknown): FavoriteSkill { + const parsed = favoriteSchema.safeParse(input); + if (!parsed.success) throw new FavoritesError('INVALID_INPUT'); + return parsed.data; +} +function key(item: FavoriteSkill): string { return JSON.stringify([item.namespace, item.slug]); } +function project(items: StoredFavorite[], registry: string): FavoriteList { + return { items: items.filter(item => item.registry === registry).map(({ namespace, slug, displayName, summary }) => ({ + namespace, slug, ...(displayName === undefined ? {} : { displayName }), ...(summary === undefined ? {} : { summary }), + })) }; +} + +/** User preferences only: no inventory, skill directory, or Registry mutation. */ +export class FavoritesStore { + constructor(private readonly home?: string) {} + + async list(registry: string, assertCurrent: () => void = () => {}): Promise { + const source = sourceIdentity(registry, 'INVALID_INPUT'); + const file = await this.location(false); + const document = file ? await this.read(file) : { version: 1, items: [] }; + assertCurrent(); + return project(document.items, source); + } + + async set(registry: string, input: FavoriteSkill, favorite: boolean, assertCurrent: () => void = () => {}): Promise { + const source = sourceIdentity(registry, 'INVALID_INPUT'); + const parsed = favoriteSchema.safeParse(input); + if (!parsed.success || typeof favorite !== 'boolean') throw new FavoritesError('INVALID_INPUT'); + const check = () => { try { assertCurrent(); } catch (error) { throw new ContextChanged(error); } }; + try { + check(); + const file = await this.location(true); + if (!file) throw new FavoritesError('UNAVAILABLE'); + // Import lazily: an unavailable optional preference dependency must not disable inventory operations. + const { withFileLock, writeFileAtomic } = await import('@deepseek-ai/dsh-atomic-write'); + return await withFileLock(file, async () => { + check(); + if (await this.location(false) !== file) throw new FavoritesError('INVALID_STORE'); + const document = await this.read(file); + const identity = key(parsed.data); + const existing = document.items.findIndex(item => item.registry === source && key(item) === identity); + let items = document.items; + if (favorite) { + if (existing < 0 && items.length >= favoriteLimits.items) throw new FavoritesError('LIMIT_REACHED'); + const next = { registry: source, ...parsed.data }; + items = existing < 0 ? [next, ...items] : items.map((item, index) => index === existing ? next : item); + } else if (existing >= 0) items = items.filter((_, index) => index !== existing); + const content = JSON.stringify({ version: 1, items }) + '\n'; + if (Buffer.byteLength(content) > favoriteLimits.bytes) throw new FavoritesError('LIMIT_REACHED'); + check(); + if (JSON.stringify(document.items) !== JSON.stringify(items)) await writeFileAtomic(file, content, { mode: 0o600, dirMode: 0o700 }); + return project(items, source); + }, { waitMs: 2000 }); + } catch (error) { + if (error instanceof ContextChanged) throw error.original; + if (error instanceof FavoritesError) throw error; + throw new FavoritesError('UNAVAILABLE'); + } + } + + private async location(create: boolean): Promise { + try { + const home = this.home ?? resolveDshHome(); + if (create) await mkdir(home, { recursive: true, mode: 0o700 }); + const canonical = await realpath(home); + if (!(await lstat(canonical)).isDirectory()) throw new FavoritesError('INVALID_STORE'); + const directory = join(canonical, 'skillhub'); + if (create) { + try { await mkdir(directory, { mode: 0o700 }); } + catch (error) { if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) throw error; } + } + const stat = await lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new FavoritesError('INVALID_STORE'); + return join(directory, 'favorites.json'); + } catch (error) { + if (!create && missing(error)) return undefined; + if (error instanceof FavoritesError) throw error; + throw new FavoritesError('UNAVAILABLE'); + } + } + + private async read(file: string): Promise { + try { + const before = await lstat(file); + if (!before.isFile() || before.isSymbolicLink() || before.size > favoriteLimits.bytes) throw new FavoritesError('INVALID_STORE'); + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > favoriteLimits.bytes) throw new FavoritesError('INVALID_STORE'); + const bytes = await handle.readFile(); + if (bytes.length > favoriteLimits.bytes) throw new FavoritesError('INVALID_STORE'); + let raw: unknown; + try { raw = JSON.parse(bytes.toString('utf8')); } catch { throw new FavoritesError('INVALID_STORE'); } + const result = documentSchema.safeParse(raw); + if (!result.success) throw new FavoritesError('INVALID_STORE'); + const seen = new Set(); + for (const item of result.data.items) { + if (sourceIdentity(item.registry, 'INVALID_STORE') !== item.registry) throw new FavoritesError('INVALID_STORE'); + const identity = JSON.stringify([item.registry, item.namespace, item.slug]); + if (seen.has(identity)) throw new FavoritesError('INVALID_STORE'); + seen.add(identity); + } + return result.data; + } finally { await handle.close(); } + } catch (error) { + if (missing(error)) return { version: 1, items: [] }; + if (error instanceof FavoritesError) throw error; + throw new FavoritesError('UNAVAILABLE'); + } + } +} diff --git a/plugins/dsh/src/host/index.ts b/plugins/dsh/src/host/index.ts index 6e5c7122b..f30c5dcc4 100644 --- a/plugins/dsh/src/host/index.ts +++ b/plugins/dsh/src/host/index.ts @@ -29,6 +29,8 @@ import type { ConversationSearchContext, ConversationSearchResult } from './sear import type { RegistryConnectionTest, CatalogContext, CatalogLabels, CatalogPageResult, CatalogSort, ModuleAvailability, ReviewPage, ConversationDetail, SkillDetail, SkillList, VersionPage } from './types.js'; import { RegistryAdapter, RegistryError } from './registry-adapter.js'; import { LocalRuntime } from './local-runtime.js'; +import { FavoritesStore, FavoritesError, validateFavoriteSkill } from './favorites-store.js'; +import type { FavoriteList, FavoriteSkill, FavoriteVersion } from './types.js'; import { assertRuntimeCompatibility } from './runtime-compatibility.js'; import { supportedDshVersions } from './compatibility.js'; import type { HostCompatibility } from './types.js'; @@ -70,6 +72,7 @@ export class SkillHubService extends TypertRemoteService { private sourceInput = ''; private readonly lifetime = new AbortController(); private localRuntime?: LocalRuntime; + private readonly favorites = new FavoritesStore(); private readonly selections = new ConversationSelections(); private readonly pendingConversationInstalls = new Map; confirmation?: Promise }>(); private readonly pendingConversationChoices = new Map(); @@ -215,6 +218,42 @@ export class SkillHubService extends TypertRemoteService { }; } + @Remote + async listFavorites(sourceRevision: string, signal: AbortSignal): Promise { + return this.readSource(sourceRevision, signal, (registry, combined) => this.readFavorites(() => + this.favorites.list(registry.source, () => { combined.throwIfAborted(); this.assertSource(sourceRevision); }))); + } + + @Remote + async setFavorite(sourceRevision: string, item: FavoriteSkill, favorite: boolean, signal: AbortSignal): Promise { + return this.readSource(sourceRevision, signal, (registry, combined) => this.readFavorites(() => + this.favorites.set(registry.source, item, favorite, () => { combined.throwIfAborted(); this.assertSource(sourceRevision); }))); + } + + @Remote + async resolveFavoriteVersion(sourceRevision: string, namespace: string, slug: string, signal: AbortSignal): Promise { + return this.readSource(sourceRevision, signal, async (registry, combined) => { + this.readFavoriteCoordinates(namespace, slug); + const resolved = await registry.resolve(namespace, slug, undefined, combined); + return { namespace: resolved.namespace, slug: resolved.slug, version: resolved.version }; + }); + } + + private readFavoriteCoordinates(namespace: string, slug: string): void { + try { validateFavoriteSkill({ namespace, slug }); } + catch { throw new RemoteError('skillhub/favorites-error', 'Invalid favorite identity.', { reason: 'INVALID_INPUT', retryable: false }); } + } + + private async readFavorites(read: () => Promise): Promise { + try { return await read(); } + catch (error) { + if (error instanceof FavoritesError) throw new RemoteError('skillhub/favorites-error', 'Local favorites are unavailable.', { + reason: error.reason, retryable: error.reason === 'UNAVAILABLE', + }); + throw error; + } + } + @Remote async listSkills(sourceRevision: string, query: string, signal: AbortSignal): Promise { return this.readSource(sourceRevision, signal, async (registry, signal) => { diff --git a/plugins/dsh/src/host/types.ts b/plugins/dsh/src/host/types.ts index a3c54486a..aa24c563c 100644 --- a/plugins/dsh/src/host/types.ts +++ b/plugins/dsh/src/host/types.ts @@ -49,6 +49,16 @@ export interface SkillList { limit: number; } +/** Local display snapshot; identity excludes versions and grants no install authority. */ +export interface FavoriteSkill { + namespace: string; + slug: string; + displayName?: string; + summary?: string; +} +export interface FavoriteList { items: FavoriteSkill[] } +export interface FavoriteVersion { namespace: string; slug: string; version: string } + export interface CatalogLabel { slug: string; type: 'RECOMMENDED' | 'PRIVILEGED'; displayName: string } export interface CatalogLabels { available: boolean; items: CatalogLabel[] } @@ -158,6 +168,7 @@ export interface ReviewPage { declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'skillhub/favorites-error': { reason: 'INVALID_INPUT' | 'UNAVAILABLE' | 'INVALID_STORE' | 'LIMIT_REACHED'; retryable: boolean }; 'skillhub/source-not-configured': { retryable: false }; 'skillhub/context-stale': { retryable: false }; 'skillhub/installation-unavailable': { retryable: boolean }; diff --git a/plugins/dsh/test/favorites-service.test.ts b/plugins/dsh/test/favorites-service.test.ts new file mode 100644 index 000000000..5fd6540c7 --- /dev/null +++ b/plugins/dsh/test/favorites-service.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; +import { Context } from '@deepseek-ai/cordis'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { withFileLock } from '@deepseek-ai/dsh-atomic-write'; +import SkillHubService from '@astron-team/skillhub-dsh'; + +const item = { namespace: 'team', slug: 'writer', summary: 'Write documents.' }; +async function privateHome(run: (home: string) => Promise) { + const before = process.env.DSH_HOME; + const home = await mkdtemp(join(tmpdir(), 'dsh-favorites-service-')); + process.env.DSH_HOME = home; + try { await run(home); } + finally { if (before === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = before; await rm(home, { recursive: true, force: true }); } +} + +describe('native local favorites service', () => { + test('preference reads/writes issue no Registry HTTP, isolate sources and expose stable storage errors', () => privateHome(async home => { + const requests: string[] = []; + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { requests.push(request.url); return new Response('Unexpected request', { status: 500 }); } }); + const ctx = new Context(); let source = server.url.href; + const fiber = ctx.plugin(ctx => { new SkillHubService(ctx, { get: () => ({ registryUrl: source, detailTabs: { versions: 'auto', securityAudit: 'auto', reviews: 'auto' } }) }); }); + await fiber; + try { + const signal = new AbortController().signal; + const revisionA = ctx.skillhub.getContext().sourceRevision; + expect(await ctx.skillhub.listFavorites(revisionA, signal)).toEqual({ items: [] }); + expect(await ctx.skillhub.setFavorite(revisionA, item, true, signal)).toEqual({ items: [item] }); + source = server.url.href + 'other'; const revisionB = ctx.skillhub.getContext().sourceRevision; + expect(await ctx.skillhub.listFavorites(revisionB, signal)).toEqual({ items: [] }); + await ctx.skillhub.setFavorite(revisionB, item, true, signal); + await ctx.skillhub.setFavorite(revisionB, item, false, signal); + source = server.url.href; const renewedA = ctx.skillhub.getContext().sourceRevision; + expect(await ctx.skillhub.listFavorites(renewedA, signal)).toEqual({ items: [item] }); + expect(requests).toEqual([]); + const file = join(home, 'skillhub/favorites.json'); + const disk = JSON.parse(await readFile(file, 'utf8')); + expect(disk.items).toEqual([{ registry: server.url.href.replace(/\/$/u, ''), ...item }]); + await writeFile(file, '{damaged-private-path'); + await expect(ctx.skillhub.listFavorites(renewedA, signal)).rejects.toMatchObject({ code: 'skillhub/favorites-error', details: { reason: 'INVALID_STORE', retryable: false } }); + await expect(ctx.skillhub.setFavorite(renewedA, { ...item, slug: '' }, true, signal)).rejects.toMatchObject({ code: 'skillhub/favorites-error', details: { reason: 'INVALID_INPUT' } }); + expect(await readFile(file, 'utf8')).toBe('{damaged-private-path'); + } finally { await fiber.dispose(); await server.stop(true); } + })); + + test('source switch while waiting for the lock refuses the pending preference write', () => privateHome(async home => { + const ctx = new Context(); let source = 'https://a.example'; + const fiber = ctx.plugin(ctx => { new SkillHubService(ctx, { get: () => ({ registryUrl: source, detailTabs: { versions: 'auto', securityAudit: 'auto', reviews: 'auto' } }) }); }); + await fiber; + const held = Promise.withResolvers(), release = Promise.withResolvers(); + try { + const revision = ctx.skillhub.getContext().sourceRevision, signal = new AbortController().signal; + await ctx.skillhub.setFavorite(revision, item, true, signal); + const file = join(home, 'skillhub/favorites.json'), before = await readFile(file); + const lock = withFileLock(file, async () => { held.resolve(); await release.promise; }); await held.promise; + const outcome = ctx.skillhub.setFavorite(revision, { ...item, slug: 'next' }, true, signal).then(() => null, (error: unknown) => error); + source = 'https://b.example'; release.resolve(); await lock; + expect(await outcome).toMatchObject({ code: 'skillhub/context-stale' }); + expect(await readFile(file)).toEqual(before); + expect(await ctx.skillhub.listFavorites(ctx.skillhub.getContext().sourceRevision, signal)).toEqual({ items: [] }); + } finally { release.resolve(); await fiber.dispose(); } + })); + + test('latest favorite selection resolves one identity without pinning snapshots or exposing download authority', async () => { + const paths: string[] = []; + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { + const url = new URL(request.url); paths.push(request.method + ' ' + url.pathname + url.search); + return Response.json({ data: { namespace: 'team', slug: 'writer', version: '2.0.0', versionId: 22, fingerprint: 'new-content', downloadUrl: 'https://storage.example/private' } }); + } }); + const ctx = new Context(), fiber = ctx.plugin(SkillHubService, { registryUrl: server.url.href }); await fiber; + try { + const revision = ctx.skillhub.getContext().sourceRevision, signal = new AbortController().signal; + const value = await ctx.skillhub.resolveFavoriteVersion(revision, 'team', 'writer', signal); + expect(value).toEqual({ namespace: 'team', slug: 'writer', version: '2.0.0' }); + expect(paths).toEqual(['GET /api/cli/v1/skills/team/writer/resolve']); + await expect(ctx.skillhub.resolveFavoriteVersion(revision, '..', 'writer', signal)).rejects.toMatchObject({ code: 'skillhub/favorites-error', details: { reason: 'INVALID_INPUT' } }); + expect(paths).toHaveLength(1); + } finally { await fiber.dispose(); await server.stop(true); } + }); + + test('late latest-version responses cannot enter a changed source', async () => { + const reached = Promise.withResolvers(), release = Promise.withResolvers(); + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch() { reached.resolve(); await release.promise; return Response.json({ data: { namespace: 'team', slug: 'writer', version: '2.0.0', versionId: 22, fingerprint: 'abc', downloadUrl: '/download' } }); } }); + const ctx = new Context(); let source = server.url.href; + const fiber = ctx.plugin(ctx => { new SkillHubService(ctx, { get: () => ({ registryUrl: source, detailTabs: { versions: 'auto', securityAudit: 'auto', reviews: 'auto' } }) }); }); await fiber; + try { + const outcome = ctx.skillhub.resolveFavoriteVersion(ctx.skillhub.getContext().sourceRevision, 'team', 'writer', new AbortController().signal).then(() => null, (error: unknown) => error); + await reached.promise; source = 'https://b.example'; release.resolve(); + expect(await outcome).toMatchObject({ code: 'skillhub/context-stale' }); + } finally { release.resolve(); await fiber.dispose(); await server.stop(true); } + }); +}); diff --git a/plugins/dsh/test/favorites-store.test.ts b/plugins/dsh/test/favorites-store.test.ts new file mode 100644 index 000000000..55ff36934 --- /dev/null +++ b/plugins/dsh/test/favorites-store.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { withFileLock } from '@deepseek-ai/dsh-atomic-write'; +import { FavoritesStore, favoriteLimits } from '../src/host/favorites-store.ts'; + +const source = 'https://registry.example/team'; +const item = { namespace: 'team', slug: 'writer', displayName: 'Writer', summary: 'Write documents.' }; +async function fixture(run: (root: string, store: FavoritesStore, file: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'dsh-favorites-')); + try { await run(root, new FavoritesStore(root), join(root, 'skillhub/favorites.json')); } + finally { await rm(root, { recursive: true, force: true }); } +} + +describe('local favorites preferences', () => { + test('source-isolated snapshots survive new stores, explicit retries and inventory remains unchanged', () => fixture(async (root, store, file) => { + expect(await store.list(source)).toEqual({ items: [] }); + const inventory = join(root, 'inventory.json'); await writeFile(inventory, 'inventory sentinel'); + await store.set('HTTPS://REGISTRY.EXAMPLE:443/team///', item, true); + await store.set(source, item, true); + await store.set('https://registry.example/other', item, true); + await store.set(source, { ...item, namespace: 'other' }, true); + const saved = JSON.parse(await readFile(file, 'utf8')); + expect(saved.version).toBe(1); expect(saved.items).toHaveLength(3); + expect(saved.items.map((record: { registry: string; namespace: string }) => [record.registry, record.namespace])).toEqual([ + [source, 'other'], ['https://registry.example/other', 'team'], [source, 'team'], + ]); + expect(await new FavoritesStore(root).list(source)).toEqual({ items: [{ ...item, namespace: 'other' }, item] }); + expect(await new FavoritesStore(root).list('https://registry.example/other')).toEqual({ items: [item] }); + await store.set(source, item, false); await store.set(source, item, false); + expect(await store.list(source)).toEqual({ items: [{ ...item, namespace: 'other' }] }); + expect(await readFile(inventory, 'utf8')).toBe('inventory sentinel'); + if (process.platform !== 'win32') { + expect((await stat(file)).mode & 0o777).toBe(0o600); + expect((await stat(join(root, 'skillhub'))).mode & 0o777).toBe(0o700); + } + })); + + test.each([ + '{broken', JSON.stringify({ version: 2, items: [] }), JSON.stringify({ version: 1, items: [{ registry: source, namespace: 'team' }] }), + JSON.stringify({ version: 1, items: [{ registry: source, ...item }, { registry: source, ...item }] }), + ])('damaged or unknown stores are not silently replaced (%s)', content => fixture(async (root, store, file) => { + await mkdir(join(root, 'skillhub')); await writeFile(file, content); + await expect(store.list(source)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + expect(await readFile(file, 'utf8')).toBe(content); + })); + + test('invalid inputs and collection/byte limits preserve original bytes', () => fixture(async (_root, store, file) => { + await store.set(source, item, true); const before = await readFile(file); + for (const invalid of [{ ...item, namespace: '..' }, { ...item, slug: '' }, { ...item, slug: 'x'.repeat(257) }, { ...item, summary: 'x'.repeat(1025) }]) { + await expect(store.set(source, invalid, true)).rejects.toMatchObject({ reason: 'INVALID_INPUT' }); + } + await expect(store.set('https://user:credential@registry.example', item, true)).rejects.toMatchObject({ reason: 'INVALID_INPUT' }); + expect(await readFile(file)).toEqual(before); + const full = JSON.stringify({ version: 1, items: Array.from({ length: favoriteLimits.items }, (_, index) => ({ registry: source, namespace: 'team', slug: `skill-${index}` })) }); + await writeFile(file, full); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'LIMIT_REACHED' }); + expect(await readFile(file, 'utf8')).toBe(full); + await store.set(source, { namespace: 'team', slug: 'skill-0' }, false); + expect(JSON.parse(await readFile(file, 'utf8')).items).toHaveLength(favoriteLimits.items - 1); + await writeFile(file, ' '.repeat(favoriteLimits.bytes + 1)); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + expect((await stat(file)).size).toBe(favoriteLimits.bytes + 1); + })); + + test.skipIf(process.platform === 'win32')('symlink files and directories cannot redirect preference reads or writes', () => fixture(async (root, store, file) => { + const outside = join(root, 'outside'); await mkdir(outside); + const sentinel = join(outside, 'sentinel'); await writeFile(sentinel, 'private sentinel'); + await mkdir(join(root, 'skillhub')); await symlink(sentinel, file); + await expect(store.list(source)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + expect(await readFile(sentinel, 'utf8')).toBe('private sentinel'); + await rm(join(root, 'skillhub'), { recursive: true }); await symlink(outside, join(root, 'skillhub'), 'dir'); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE' }); + expect(await readFile(sentinel, 'utf8')).toBe('private sentinel'); + })); + + test('waiting for an official lock rechecks context before committing', () => fixture(async (_root, store, file) => { + await store.set(source, item, true); const before = await readFile(file); + const held = Promise.withResolvers(); const release = Promise.withResolvers(); + const lock = withFileLock(file, async () => { held.resolve(); await release.promise; }); await held.promise; + let valid = true; let checks = 0; const expected = new Error('context changed'); + const result = store.set(source, { ...item, slug: 'next' }, true, () => { checks++; if (!valid) throw expected; }).then(() => undefined, error => error); + valid = false; release.resolve(); await lock; + expect(await result).toBe(expected); expect(checks).toBeGreaterThanOrEqual(2); + expect(await readFile(file)).toEqual(before); + })); + + test('non-file or unavailable storage exposes stable reasons without raw diagnostics', () => fixture(async (root, store, file) => { + await mkdir(join(root, 'skillhub')); await mkdir(file); + await expect(store.set(source, item, true)).rejects.toMatchObject({ reason: 'INVALID_STORE', message: 'INVALID_STORE' }); + await rm(file, { recursive: true }); await writeFile(file, '{broken'); + const error = await store.list(source).catch(error => error); expect(error.message).not.toContain(root); + const blocked = new FavoritesStore(file); + await expect(blocked.set(source, item, true)).rejects.toMatchObject({ reason: 'UNAVAILABLE', message: 'UNAVAILABLE' }); + })); + + test('two Node writers contend behind an explicit lock barrier without losing either record', () => fixture(async (root, store, file) => { + await store.set(source, item, true); + const build = await Bun.build({ entrypoints: [fileURLToPath(new URL('../src/host/favorites-store.ts', import.meta.url))], target: 'node', format: 'esm', packages: 'external' }); + if (!build.success) throw new AggregateError(build.logs, 'Store test build failed'); + await writeFile(join(root, 'store.mjs'), await build.outputs[0]!.text()); + await symlink(fileURLToPath(new URL('../node_modules', import.meta.url)), join(root, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); + const runner = join(root, 'writer.mjs'); + await writeFile(runner, `import {FavoritesStore} from './store.mjs'; +process.send({type:'ready'}); +try {const value=await new FavoritesStore(process.argv[2]).set(process.argv[3],{namespace:'team',slug:process.argv[4]},true);process.send({type:'saved',count:value.items.length});process.disconnect();} catch(error){process.send({type:'failed',reason:error.reason??error.name});process.disconnect();process.exitCode=1;}`); + const children: ReturnType[] = [], counts: number[] = [], completions: Promise[] = [], ready: Promise[] = []; + try { + await withFileLock(file, async () => { + for (const slug of ['first', 'second']) { + const child = spawn('node', [runner, root, source, slug], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); children.push(child); + ready.push(new Promise((resolve, reject) => { + child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'ready') resolve(); }); + child.once('error', reject); child.once('exit', code => { if (code !== 0) reject(new Error(`Writer exited ${code}`)); }); + })); + completions.push(new Promise((resolve, reject) => { + child.on('message', message => { if (message && typeof message === 'object' && 'type' in message && message.type === 'saved' && 'count' in message && typeof message.count === 'number') counts.push(message.count); }); + child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`))); + })); + } + await Promise.all(ready); + expect(JSON.parse(await readFile(file, 'utf8')).items.map((entry: { slug: string }) => entry.slug)).toEqual(['writer']); + }); + await Promise.all(completions); + expect(counts.sort()).toEqual([2, 3]); + expect(JSON.parse(await readFile(file, 'utf8')).items.map((entry: { slug: string }) => entry.slug).sort()).toEqual(['first', 'second', 'writer']); + } finally { for (const child of children) if (child.exitCode === null) child.kill(); } + }), 15000); +}); diff --git a/plugins/dsh/test/package.test.ts b/plugins/dsh/test/package.test.ts index 2b4918adb..5904ea518 100644 --- a/plugins/dsh/test/package.test.ts +++ b/plugins/dsh/test/package.test.ts @@ -60,7 +60,7 @@ describe('native M0 package', () => { }); test('official generator publishes the declared endpoints with scoped writes and strict codec', () => { - expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'beginConversationManagement', 'beginInstalledUpdateScan', 'checkAvailability', 'checkInstalledUpdateScan', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', 'endInstalledUpdateScan', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareToggle', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'selectSkillUse', 'startInstall', 'testRegistryConnection', 'inspectLocalChanges', 'readFileChange', 'previewUpgradeChanges', 'confirmUpgradeChanges', 'prepareFileReset', 'executeFileReset', 'previewPackageImport', 'confirmPackageImport', 'exportInstalledPackage'].sort()); + expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['listFavorites', 'setFavorite', 'resolveFavoriteVersion', 'acknowledgeConversationDetail', 'beginConversationManagement', 'beginInstalledUpdateScan', 'checkAvailability', 'checkInstalledUpdateScan', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', 'endInstalledUpdateScan', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareToggle', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'selectSkillUse', 'startInstall', 'testRegistryConnection', 'inspectLocalChanges', 'readFileChange', 'previewUpgradeChanges', 'confirmUpgradeChanges', 'prepareFileReset', 'executeFileReset', 'previewPackageImport', 'confirmPackageImport', 'exportInstalledPackage'].sort()); for (const method of ['inspectLocalChanges', 'readFileChange', 'previewUpgradeChanges', 'confirmUpgradeChanges', 'prepareFileReset', 'executeFileReset', 'previewPackageImport', 'confirmPackageImport', 'exportInstalledPackage', 'beginInstalledUpdateScan', 'checkInstalledUpdateScan', 'endInstalledUpdateScan', 'checkAvailability', 'beginConversationManagement', 'confirmConversationManagement', 'openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareToggle', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) { const scoped = TYPERT_REMOTE.descriptors.find(item => item.method === method)!; expect(scoped.invocation).toMatchObject({ kind: 'context', context: 'agent', wire: 'agentId' }); @@ -70,6 +70,12 @@ describe('native M0 package', () => { expect(endpoint.cancellation).toEqual({ parameter: 'signal' }); expect(endpoint.result?.mode).toBe('strict'); } + for (const method of ['listFavorites', 'setFavorite', 'resolveFavoriteVersion']) { + const endpoint = TYPERT_REMOTE.descriptors.find(item => item.method === method)!; + expect(endpoint.cancellation).toEqual({ parameter: 'signal' }); + expect(endpoint.result?.mode).toBe('strict'); + expect(endpoint.invocation).not.toMatchObject({ kind: 'context', context: 'agent' }); + } const result = TYPERT_REMOTE.descriptors.find(item => item.method === 'getStatus')!.result; expect(result?.mode).toBe('strict'); if (result?.mode !== 'strict') throw new Error('Expected strict result codec');