GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-27-p3-diagnostics.md
2026-05-20 21:39:12 +08:00

361 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# P3: 碰撞检测与诊断系统 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 skill 扫描时收集诊断信息(名称碰撞、验证警告、环境缺失),通过 API 暴露,前端展示诊断横幅和 per-skill 诊断详情。
**Architecture:** `SkillLoaderService` 内部维护 `diagnostics: SkillDiagnostic[]` 数组,`scanSkills()` 时清空并重新收集。Admin controller 新增 `/diagnostics` 端点。前端 skills.vue 顶部新增诊断横幅skill-detail.vue 新增诊断 tab。
**Tech Stack:** TypeORM, Midway.js, Element Plus
**Spec:** `docs/superpowers/specs/2026-04-27-skill-system-evolution-design.md` Section 6
**Depends on:** P0 (envSchema for ENV_NOT_CONFIGURED check), P1 Tasks 1-2 (SkillConfigService integrated into SkillLoaderService), P2 Task 1 (validateSkillName function). Execute these prerequisites first.
---
### Task 1: 诊断数据结构和收集
**Files:**
- Modify: `packages/shared/types/skill.types.ts`
- Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts`
- [ ] **Step 1: 在 shared/types/skill.types.ts 新增 SkillDiagnostic 类型**
```typescript
export interface SkillDiagnostic {
level: 'error' | 'warning' | 'info';
code: string;
skillName: string;
message: string;
path?: string;
detail?: Record<string, unknown>;
}
```
- [ ] **Step 2: 在 SkillLoaderService 中新增诊断收集**
在 class 内部新增:
```typescript
private diagnostics: SkillDiagnostic[] = [];
getDiagnostics(level?: string): SkillDiagnostic[] {
if (level) return this.diagnostics.filter(d => d.level === level);
return [...this.diagnostics];
}
private addDiagnostic(diag: SkillDiagnostic): void {
this.diagnostics.push(diag);
}
```
- [ ] **Step 3: 在 scanSkills 开头清空诊断**
`scanSkills()` 方法的 `this.skills.clear()` 之后添加:
```typescript
this.diagnostics = [];
const realPathSet = new Set<string>();
```
- [ ] **Step 3.5: 在 scanSkills 循环中添加 symlink 去重Spec 10.11**
在解析每个 skill 目录时,碰撞检测之前添加:
```typescript
// symlink 去重
const skillDirPath = path.join(this.skillsDir, entry.name);
let realPath: string;
try {
realPath = await fs.realpath(skillDirPath);
} catch {
realPath = skillDirPath;
}
if (realPathSet.has(realPath)) continue; // 同一物理目录,静默跳过
realPathSet.add(realPath);
```
- [ ] **Step 4: 在 scanSkills 循环中添加诊断收集**
在解析每个 skill 的过程中,添加以下检查:
```typescript
// 名称验证
const nameErrors = validateSkillName(skill.name, entry.name);
for (const err of nameErrors) {
if (err.includes('does not match')) {
this.addDiagnostic({ level: 'warning', code: 'NAME_MISMATCH', skillName: skill.name, message: err, path: skillMdPath });
} else {
this.addDiagnostic({ level: 'warning', code: 'NAME_INVALID', skillName: skill.name, message: err, path: skillMdPath });
}
}
// 碰撞检测
if (this.skills.has(skill.name)) {
const existing = this.skills.get(skill.name)!;
this.addDiagnostic({
level: 'warning', code: 'NAME_COLLISION', skillName: skill.name,
message: `Name collision: "${skill.name}" already loaded`,
detail: { winnerPath: path.join(this.skillsDir, existing.name), loserPath: skillMdPath },
});
continue; // 跳过,保留先发现的
}
// description 检查
if (!skill.description) {
this.addDiagnostic({ level: 'error', code: 'DESC_MISSING', skillName: skill.name, message: 'Missing description', path: skillMdPath });
continue; // 不加载
}
if (skill.description.length > 1024) {
this.addDiagnostic({ level: 'warning', code: 'DESC_TOO_LONG', skillName: skill.name, message: `Description exceeds 1024 chars (${skill.description.length})`, path: skillMdPath });
}
```
- [ ] **Step 5: 在 scanSkills 末尾添加 config 相关诊断**
在所有 skill 加载完成后,遍历检查 compute skill 的环境就绪状态:
```typescript
// 扫描完成后检查 compute skill 状态
for (const [name, _skill] of this.skills) {
const config = this.skillConfig.getConfig(name);
if (!config) {
// 检查是否有解析错误(区分"无 config 文件"和"config 解析失败"
const parseErr = this.skillConfig.getParseError(name);
if (parseErr) {
this.addDiagnostic({ level: 'error', code: 'CONFIG_PARSE_ERROR', skillName: name, message: `skill.config.yaml parse failed: ${parseErr}` });
}
continue;
}
// config 解析错误已在 loadConfig 中处理
// runtime 可用性检查
if (config.runtime === 'python') {
const venvPath = path.join(this.skillsDir, name, '.venv');
try { await fs.access(venvPath); } catch {
this.addDiagnostic({ level: 'warning', code: 'VENV_MISSING', skillName: name, message: 'Python .venv not found, run dependency install' });
}
}
if (config.runtime) {
const runtimeChecks: Record<string, string> = { python: 'python3 --version', node: 'node --version', dotnet: 'dotnet --version' };
const checkCmd = runtimeChecks[config.runtime];
if (checkCmd) {
try { await execAsync(checkCmd, { timeout: 5000 }); } catch {
this.addDiagnostic({ level: 'error', code: 'RUNTIME_UNAVAILABLE', skillName: name, message: `Runtime "${config.runtime}" not available on this system` });
}
}
}
// 系统依赖检查
if (config.dependencies?.system) {
for (const dep of config.dependencies.system) {
try { await execAsync(dep.check, { timeout: 5000 }); } catch {
this.addDiagnostic({ level: 'warning', code: 'SYSTEM_DEP_MISSING', skillName: name, message: `System dependency "${dep.name}" not found (check: ${dep.check})` });
}
}
}
// fingerprint 变更检查
const lockfile = await this.registry?.readLockfile?.();
if (lockfile?.skills?.[name]) {
const currentFp = await this.registry.computeFingerprint(path.join(this.skillsDir, name));
if (currentFp !== lockfile.skills[name].fingerprint) {
this.addDiagnostic({ level: 'info', code: 'FINGERPRINT_CHANGED', skillName: name, message: 'SKILL.md content changed since last install/update' });
}
}
// env 配置检查
if (config.env) {
const entity = await this.skillRepo.findOneBy({ name });
const hasSecrets = !!entity?.secrets;
for (const envItem of config.env) {
if (envItem.required && !hasSecrets) {
this.addDiagnostic({ level: 'warning', code: 'ENV_NOT_CONFIGURED', skillName: name, message: `Required env "${envItem.name}" not configured` });
break;
}
}
}
}
```
- [ ] **Step 6: Commit**
```bash
git add packages/shared/types/skill.types.ts packages/backend/src/modules/netaclaw/service/skill_loader.ts
git commit -m "feat(skill): add diagnostic collection during skill scanning"
```
---
### Task 2: Admin Controller 诊断端点
**Files:**
- Modify: `packages/backend/src/modules/netaclaw/controller/admin/skill.ts`
- [ ] **Step 1: 新增 diagnostics 端点**
```typescript
@Get('/diagnostics', { summary: '获取 skill 诊断信息' })
async diagnostics(@Query('level') level?: string) {
const diags = this.skillLoader.getDiagnostics(level);
return this.ok(diags);
}
```
- [ ] **Step 2: Commit**
```bash
git add packages/backend/src/modules/netaclaw/controller/admin/skill.ts
git commit -m "feat(skill): add diagnostics admin endpoint"
```
---
### Task 3: 前端诊断横幅
**Files:**
- Modify: `packages/frontend/src/modules/agent/views/skills.vue`
- [ ] **Step 1: 在 skills.vue 中新增诊断数据加载**
在 script 区域添加:
```typescript
const diagnostics = ref<any[]>([]);
const errorCount = computed(() => diagnostics.value.filter(d => d.level === 'error').length);
const warningCount = computed(() => diagnostics.value.filter(d => d.level === 'warning').length);
const showDiagDetails = ref(false);
async function loadDiagnostics() {
try {
const res = await service.request({ url: '/admin/netaclaw/skill/diagnostics' });
diagnostics.value = res || [];
} catch { /* ignore */ }
}
```
`refresh()` 函数末尾添加 `await loadDiagnostics();`
- [ ] **Step 2: 在 template 中 skill-header 之后添加诊断横幅**
```vue
<!-- 诊断横幅 -->
<div v-if="errorCount > 0" class="diag-banner diag-error" @click="showDiagDetails = !showDiagDetails">
{{ errorCount }} 个错误需要处理
</div>
<div v-else-if="warningCount > 0" class="diag-banner diag-warning" @click="showDiagDetails = !showDiagDetails">
{{ warningCount }} 个警告
</div>
<div v-if="showDiagDetails && diagnostics.length > 0" class="diag-list">
<div v-for="(d, i) in diagnostics" :key="i" class="diag-item" :class="'diag-' + d.level">
<el-tag :type="d.level === 'error' ? 'danger' : d.level === 'warning' ? 'warning' : 'info'" size="small">
{{ d.code }}
</el-tag>
<span class="diag-skill">{{ d.skillName }}</span>
<span>{{ d.message }}</span>
</div>
</div>
```
- [ ] **Step 3: 添加样式**
```css
.diag-banner { padding: 8px 16px; border-radius: 6px; margin-bottom: 16px; cursor: pointer; font-size: 13px; }
.diag-error { background: #fef0f0; color: #f56c6c; border: 1px solid #fbc4c4; }
.diag-warning { background: #fdf6ec; color: #e6a23c; border: 1px solid #f5dab1; }
.diag-list { background: #f5f7fa; border-radius: 6px; padding: 12px; margin-bottom: 16px; max-height: 200px; overflow-y: auto; }
.diag-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.diag-skill { font-weight: 600; color: #303133; }
```
- [ ] **Step 4: Commit**
```bash
git add packages/frontend/src/modules/agent/views/skills.vue
git commit -m "feat(skill): add diagnostic banner to skills management page"
```
---
### Task 4: skill-detail 诊断 Tab
**Files:**
- Modify: `packages/frontend/src/modules/agent/components/skill-detail.vue`
- [ ] **Step 1: 在 el-tabs 中新增诊断 tab**
在 P0 Task 6 已创建的 tab 结构中,添加第三个 tab
```vue
<el-tab-pane label="诊断" name="diagnostics">
<div v-if="skillDiagnostics.length === 0" class="empty-hint">无诊断信息</div>
<div v-else>
<div v-for="(d, i) in skillDiagnostics" :key="i" class="diag-item">
<el-tag :type="d.level === 'error' ? 'danger' : d.level === 'warning' ? 'warning' : 'info'" size="small">
{{ d.code }}
</el-tag>
<span>{{ d.message }}</span>
</div>
</div>
</el-tab-pane>
```
- [ ] **Step 2: 新增诊断数据加载逻辑**
```typescript
const skillDiagnostics = ref<any[]>([]);
async function loadSkillDiagnostics() {
if (!props.skill?.name) return;
try {
const res = await service.request({ url: '/admin/netaclaw/skill/diagnostics' });
skillDiagnostics.value = (res || []).filter((d: any) => d.skillName === props.skill.name);
} catch { /* ignore */ }
}
// 在 watch skill 的回调中添加
loadSkillDiagnostics();
```
- [ ] **Step 3: Commit**
```bash
git add packages/frontend/src/modules/agent/components/skill-detail.vue
git commit -m "feat(skill): add diagnostics tab to skill-detail drawer"
```
---
### Task 5: skills.vue 卡片 type 标签
**Files:**
- Modify: `packages/frontend/src/modules/agent/views/skills.vue`
- [ ] **Step 1: 在 metas API 返回中包含 skillTypeV2**
`SkillLoaderService.getSkillMetas()` 中,返回对象添加:
```typescript
skillTypeV2: this.skillConfig.classify(fs.name),
```
- [ ] **Step 2: 在 skill 卡片的 tags 区域添加 type 标签**
`skill-tags` div 中,现有标签之前添加:
```vue
<el-tag size="small" :type="skill.skillTypeV2 === 'compute-entry' ? 'success' : skill.skillTypeV2 === 'compute-toolkit' ? '' : 'info'">
{{ skill.skillTypeV2 || 'prompt' }}
</el-tag>
```
- [ ] **Step 3: Commit**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_loader.ts \
packages/frontend/src/modules/agent/views/skills.vue
git commit -m "feat(skill): show skill classification type tag on cards"
```