mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(dsh): persist source-isolated local favorites
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
9a25dcfaef
commit
1cce623b57
12 changed files with 503 additions and 3 deletions
|
|
@ -241,3 +241,7 @@ flowchart TB
|
|||
## 版本目录的开源接口增量
|
||||
|
||||
排序、可安装状态过滤和分页总数由开源后端统一计算;DSH 每页请求一次 `downloadableOnly=true`,不全量拉取或逐项 resolve 探测。固定版本安装仍经既有目标、指纹与包校验,管理者缺省历史行为保留。完整参数和排序契约见 [Registry API](registry-api-contract.md#版本目录的开源接口增量);SaaS 同步单独验收。
|
||||
|
||||
### 本地收藏
|
||||
|
||||
Issue #1064 的独立用户偏好、来源身份、轻量书签交互、原子写受理边界与验收见 [本地收藏设计](favorites-design.md)。该功能不改变上述Registry权威、匿名消费、库存v2或技能文件安装事务边界。
|
||||
|
|
|
|||
33
openspec/changes/add-dsh-native-plugin/favorites-design.md
Normal file
33
openspec/changes/add-dsh-native-plugin/favorites-design.md
Normal file
|
|
@ -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。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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章新增功能验收。
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
148
plugins/dsh/src/host/favorites-store.ts
Normal file
148
plugins/dsh/src/host/favorites-store.ts
Normal file
|
|
@ -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<typeof storedFavoriteSchema>;
|
||||
type Document = z.infer<typeof documentSchema>;
|
||||
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<FavoriteList> {
|
||||
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<FavoriteList> {
|
||||
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<string | undefined> {
|
||||
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<Document> {
|
||||
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<string>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, { parameters: string; agent: unknown; runtime: LocalRuntime; result: Promise<PendingConversationInstall>; confirmation?: Promise<NativeOperation | PendingConversationInstall | CancelledConversationInstall | undefined> }>();
|
||||
private readonly pendingConversationChoices = new Map<string, { agent: unknown; runtime: LocalRuntime }>();
|
||||
|
|
@ -215,6 +218,42 @@ export class SkillHubService extends TypertRemoteService {
|
|||
};
|
||||
}
|
||||
|
||||
@Remote
|
||||
async listFavorites(sourceRevision: string, signal: AbortSignal): Promise<FavoriteList> {
|
||||
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<FavoriteList> {
|
||||
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<FavoriteVersion> {
|
||||
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<T>(read: () => Promise<T>): Promise<T> {
|
||||
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<SkillList> {
|
||||
return this.readSource(sourceRevision, signal, async (registry, signal) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
93
plugins/dsh/test/favorites-service.test.ts
Normal file
93
plugins/dsh/test/favorites-service.test.ts
Normal file
|
|
@ -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<void>) {
|
||||
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<void>(), release = Promise.withResolvers<void>();
|
||||
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<void>(), release = Promise.withResolvers<void>();
|
||||
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); }
|
||||
});
|
||||
});
|
||||
134
plugins/dsh/test/favorites-store.test.ts
Normal file
134
plugins/dsh/test/favorites-store.test.ts
Normal file
|
|
@ -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<void>) {
|
||||
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<void>(); const release = Promise.withResolvers<void>();
|
||||
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<typeof spawn>[] = [], counts: number[] = [], completions: Promise<void>[] = [], ready: Promise<void>[] = [];
|
||||
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<void>((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<void>((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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue