GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-27-p3-diagnostics.md

361 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 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"
```