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

12 KiB
Raw Blame History

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 类型

export interface SkillDiagnostic {
  level: 'error' | 'warning' | 'info';
  code: string;
  skillName: string;
  message: string;
  path?: string;
  detail?: Record<string, unknown>;
}
  • Step 2: 在 SkillLoaderService 中新增诊断收集

在 class 内部新增:

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() 之后添加:

this.diagnostics = [];
const realPathSet = new Set<string>();
  • Step 3.5: 在 scanSkills 循环中添加 symlink 去重Spec 10.11

在解析每个 skill 目录时,碰撞检测之前添加:

// 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 的过程中,添加以下检查:

// 名称验证
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 的环境就绪状态:

// 扫描完成后检查 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
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 端点

@Get('/diagnostics', { summary: '获取 skill 诊断信息' })
async diagnostics(@Query('level') level?: string) {
  const diags = this.skillLoader.getDiagnostics(level);
  return this.ok(diags);
}
  • Step 2: Commit
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 区域添加:

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 之后添加诊断横幅
<!-- 诊断横幅 -->
<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: 添加样式
.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
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

<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: 新增诊断数据加载逻辑
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
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() 中,返回对象添加:

skillTypeV2: this.skillConfig.classify(fs.name),
  • Step 2: 在 skill 卡片的 tags 区域添加 type 标签

skill-tags div 中,现有标签之前添加:

<el-tag size="small" :type="skill.skillTypeV2 === 'compute-entry' ? 'success' : skill.skillTypeV2 === 'compute-toolkit' ? '' : 'info'">
  {{ skill.skillTypeV2 || 'prompt' }}
</el-tag>
  • Step 3: Commit
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"