guanghulab/scripts/psp-inspection.js
2026-05-10 13:12:44 +08:00

394 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// scripts/psp-inspection.js
// 铸渊 PSP 分身巡检 · 每日自动检查清单
// CHK-G01 信号完整性 / CHK-G02 dev-nodes 一致性 / CHK-G03 notion-push 清理
// CHK-G04 broadcasts 投递 / CHK-G05 CI 健康
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const nodemailer = require('nodemailer');
// ── 目录配置 ───────────────────────────────────────────────────────────────
const ROOT = path.join(__dirname, '..');
const SIGNAL_LOG_DIR = path.join(ROOT, 'signal-log');
const SIGNAL_INDEX = path.join(SIGNAL_LOG_DIR, 'index.json');
const DEV_NODES_DIR = path.join(ROOT, 'dev-nodes');
const NOTION_PENDING = path.join(ROOT, 'notion-push/pending');
const BROADCASTS_DIR = path.join(ROOT, 'broadcasts-outbox');
const MEMORY_PATH = path.join(ROOT, '.github/brain/memory.json');
const EMAIL_ADDRESS = process.env.EMAIL_ADDRESS;
const EMAIL_PASSWORD = process.env.EMAIL_APP_PASSWORD;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const REPO = process.env.GITHUB_REPOSITORY || 'qinfendebingshuo/guanghulab';
const now = new Date();
const today = now.toISOString().split('T')[0];
const results = {};
const issues = [];
const autoFixed = [];
const MS_PER_HOUR = 3600000;
const PSP_TRACE_ID = `TRC-${today.replace(/-/g, '')}-PSP`;
// ── 工具函数 ───────────────────────────────────────────────────────────────
function hoursAgo(isoStr) {
if (!isoStr) return Infinity;
return (Date.now() - new Date(isoStr).getTime()) / MS_PER_HOUR;
}
function loadJson(filePath) {
if (!fs.existsSync(filePath)) return null;
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
}
function saveJson(filePath, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
// ── 信号 ID 生成 ───────────────────────────────────────────────────────────
function generateSignalId() {
const ymd = today.replace(/-/g, '');
const index = loadJson(SIGNAL_INDEX) || { total_count: 0 };
const seq = String(index.total_count + 1).padStart(3, '0');
return `SIG-${ymd}-${seq}`;
}
// ── 写信号日志 ─────────────────────────────────────────────────────────────
function writeSignalLog(signal) {
const dateStr = signal.timestamp.slice(0, 7);
const dir = path.join(SIGNAL_LOG_DIR, dateStr);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${signal.signal_id}.json`), JSON.stringify(signal, null, 2));
const index = loadJson(SIGNAL_INDEX) || { last_updated: '', total_count: 0, signals: [] };
index.total_count += 1;
index.last_updated = now.toISOString();
index.signals.unshift({
signal_id: signal.signal_id,
trace_id: signal.trace_id,
type: signal.signal_type,
timestamp: signal.timestamp,
summary: signal.summary,
related_dev: signal.related_dev || null,
file: `${dateStr}/${signal.signal_id}.json`
});
saveJson(SIGNAL_INDEX, index);
}
// ── 发送 GL-DATA 通知邮件 ──────────────────────────────────────────────────
async function sendDataEmail(subject, payload) {
if (!EMAIL_ADDRESS || !EMAIL_PASSWORD) {
console.log('⚠️ EMAIL 未配置,跳过邮件发送');
return;
}
const transport = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: { user: EMAIL_ADDRESS, pass: EMAIL_PASSWORD }
});
await transport.sendMail({
from: EMAIL_ADDRESS,
to: EMAIL_ADDRESS,
subject: `[GL-DATA] ${subject}`,
text: JSON.stringify(payload, null, 2)
});
console.log(`📧 [GL-DATA] 已发送: ${subject}`);
}
// ── CHK-G01 信号收发完整性 ─────────────────────────────────────────────────
function checkG01() {
console.log('\n─── CHK-G01 信号收发完整性 ───');
const index = loadJson(SIGNAL_INDEX);
if (!index) {
results.G01 = '⚠️';
issues.push('CHK-G01: signal-log/index.json 不存在');
return;
}
const cutoff = Date.now() - 24 * MS_PER_HOUR;
const recent = index.signals.filter(s => new Date(s.timestamp).getTime() > cutoff);
const cmds = recent.filter(s => s.type === 'GL-CMD');
const acks = new Set(recent.filter(s => s.type === 'GL-ACK').map(s => s.trace_id));
const unacked = cmds.filter(s => !acks.has(s.trace_id));
if (unacked.length > 0) {
results.G01 = '❌';
unacked.forEach(s => issues.push(`CHK-G01: 未收到 ACK · trace_id=${s.trace_id} · ${s.summary}`));
} else {
results.G01 = '✅';
console.log(` ✅ 最近24h CMD: ${cmds.length} · ACK 全部到位`);
}
}
// ── CHK-G02 dev-nodes 状态一致性 ──────────────────────────────────────────
function checkG02() {
console.log('\n─── CHK-G02 dev-nodes 状态一致性 ───');
if (!fs.existsSync(DEV_NODES_DIR)) {
results.G02 = '❌';
issues.push('CHK-G02: dev-nodes/ 目录不存在');
return;
}
const devDirs = fs.readdirSync(DEV_NODES_DIR).filter(d => d.startsWith('DEV-'));
let allOk = true;
for (const devId of devDirs) {
const statusPath = path.join(DEV_NODES_DIR, devId, 'status.json');
const configPath = path.join(DEV_NODES_DIR, devId, 'config.json');
if (!fs.existsSync(statusPath) || !fs.existsSync(configPath)) {
issues.push(`CHK-G02: ${devId} 缺少 status.json 或 config.json`);
allOk = false;
continue;
}
const status = loadJson(statusPath);
if (!status) {
issues.push(`CHK-G02: ${devId} status.json 解析失败`);
allOk = false;
}
}
results.G02 = allOk ? '✅' : '⚠️';
if (allOk) console.log(`${devDirs.length} 个开发者节点状态文件完整`);
}
// ── CHK-G03 notion-push 清理 ───────────────────────────────────────────────
async function checkG03() {
console.log('\n─── CHK-G03 notion-push/pending 清理 ───');
if (!fs.existsSync(NOTION_PENDING)) {
results.G03 = '✅';
console.log(' ✅ notion-push/pending 目录为空或不存在');
return;
}
const files = fs.readdirSync(NOTION_PENDING).filter(f => f.endsWith('.json'));
const stale = files.filter(f => {
const stat = fs.statSync(path.join(NOTION_PENDING, f));
return (Date.now() - stat.mtimeMs) > 24 * MS_PER_HOUR;
});
if (stale.length > 0) {
results.G03 = '⚠️';
issues.push(`CHK-G03: ${stale.length} 个 notion-push/pending 文件超过24h未处理: ${stale.join(', ')}`);
// 发通知
try {
await sendDataEmail('notion-push/pending 文件积压提醒', {
type: 'GL-CMD',
sender: '铸渊',
command: 'alert_pending_files',
payload: { stale_files: stale, count: stale.length }
});
} catch (err) {
console.error(` ⚠️ 发送通知失败: ${err.message}`);
}
} else {
results.G03 = '✅';
console.log(` ✅ pending: ${files.length} 个文件,无超期`);
}
}
// ── CHK-G04 broadcasts 投递确认 ───────────────────────────────────────────
function checkG04() {
console.log('\n─── CHK-G04 broadcasts-outbox 投递确认 ───');
if (!fs.existsSync(BROADCASTS_DIR)) {
results.G04 = '⚠️';
issues.push('CHK-G04: broadcasts-outbox/ 不存在');
return;
}
const devDirs = fs.readdirSync(BROADCASTS_DIR).filter(d => d.startsWith('DEV-'));
let allSynced = true;
for (const devId of devDirs) {
const srcDir = path.join(BROADCASTS_DIR, devId);
const destDir = path.join(DEV_NODES_DIR, devId);
if (!fs.existsSync(destDir)) continue;
const broadcasts = fs.readdirSync(srcDir).filter(f => f.endsWith('.json'));
for (const file of broadcasts) {
const dest = path.join(destDir, file);
if (!fs.existsSync(dest)) {
// 自动复制
fs.copyFileSync(path.join(srcDir, file), dest);
autoFixed.push(`CHK-G04: 自动投递广播 ${devId}/${file}`);
console.log(` 🔧 自动投递: ${devId}/${file}`);
allSynced = false;
}
}
}
results.G04 = '✅';
if (allSynced) {
console.log(' ✅ 所有广播已同步至 dev-nodes/');
} else {
console.log(` ✅ 已自动修复 ${autoFixed.length} 个广播投递`);
}
}
// ── CHK-G05 CI 健康检查 ───────────────────────────────────────────────────
async function checkG05() {
console.log('\n─── CHK-G05 CI 健康检查 ───');
if (!GITHUB_TOKEN) {
results.G05 = '⚠️';
console.log(' ⚠️ GITHUB_TOKEN 未配置,跳过 CI 检查');
return;
}
return new Promise((resolve) => {
const [owner, repoName] = REPO.split('/');
const options = {
hostname: 'api.github.com',
path: `/repos/${owner}/${repoName}/actions/runs?per_page=20`,
method: 'GET',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'zhuyuan-psp-inspector'
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const body = JSON.parse(data);
const runs = body.workflow_runs || [];
const cutoff = Date.now() - 24 * MS_PER_HOUR;
const recent = runs.filter(r => new Date(r.created_at).getTime() > cutoff);
const failed = recent.filter(r => r.conclusion === 'failure');
if (failed.length > 0) {
results.G05 = '❌';
failed.forEach(r => {
issues.push(`CHK-G05: CI 失败 · ${r.name} · ${r.html_url}`);
});
// 写入 signal-log
const signalId = generateSignalId();
writeSignalLog({
signal_id: signalId,
trace_id: PSP_TRACE_ID,
timestamp: now.toISOString(),
signal_type: 'GL-DATA',
direction: 'GitHub→Notion',
sender: '铸渊',
receiver: '霜砚',
related_dev: null,
related_module: null,
summary: `CHK-G05: ${failed.length} 个 CI 失败`,
payload: { failed_runs: failed.map(r => ({ name: r.name, url: r.html_url })) },
result: '待处理',
ack_signal_id: null
});
} else {
results.G05 = '✅';
console.log(` ✅ 最近24h CI: ${recent.length} 次运行,全部通过`);
}
} catch (err) {
results.G05 = '⚠️';
console.log(` ⚠️ CI 数据解析失败: ${err.message}`);
}
resolve();
});
});
req.on('error', (err) => {
results.G05 = '⚠️';
console.log(` ⚠️ GitHub API 请求失败: ${err.message}`);
resolve();
});
req.end();
});
}
// ── 更新 memory.json ───────────────────────────────────────────────────────
function updateMemory(summary) {
const memory = loadJson(MEMORY_PATH);
if (!memory) return;
if (!memory.events) memory.events = [];
memory.events.unshift({
date: today,
type: 'psp_inspection',
description: summary,
by: '铸渊PSP巡检'
});
if (memory.events.length > 50) memory.events = memory.events.slice(0, 50);
memory.last_updated = now.toISOString();
saveJson(MEMORY_PATH, memory);
}
// ── 写入巡检结果到 signal-log ──────────────────────────────────────────────
function writeInspectionSignal(allPassed) {
const signalId = generateSignalId();
const summary = allPassed
? '铸渊 PSP 巡检通过 · 全部检查项 ✅'
: `铸渊 PSP 巡检完成 · 发现 ${issues.length} 个问题 · 自动修复 ${autoFixed.length}`;
writeSignalLog({
signal_id: signalId,
trace_id: PSP_TRACE_ID,
timestamp: now.toISOString(),
signal_type: 'GL-DATA',
direction: 'GitHub→Notion',
sender: '铸渊',
receiver: '霜砚',
related_dev: null,
related_module: null,
summary,
payload: {
check_results: results,
issues,
auto_fixed: autoFixed
},
result: allPassed ? '全通过' : '有问题',
ack_signal_id: null
});
return summary;
}
// ── 主流程 ────────────────────────────────────────────────────────────────
async function main() {
console.log('═══════════════════════════════════════════════════');
console.log('🔍 铸渊 PSP 分身巡检启动');
console.log(` 日期: ${today}`);
console.log('═══════════════════════════════════════════════════');
checkG01();
checkG02();
await checkG03();
checkG04();
await checkG05();
const allPassed = Object.values(results).every(v => v === '✅');
const summary = writeInspectionSignal(allPassed);
updateMemory(summary);
console.log('\n═══════════════════════════════════════════════════');
console.log('📊 PSP 巡检报告:');
Object.entries(results).forEach(([k, v]) => console.log(` ${v} CHK-${k}`));
if (autoFixed.length > 0) {
console.log('\n🔧 自动修复:');
autoFixed.forEach(f => console.log(` · ${f}`));
}
if (issues.length > 0) {
console.log('\n⚠ 待处理问题:');
issues.forEach(i => console.log(` · ${i}`));
}
console.log(`\n${allPassed ? '✅ 铸渊 PSP 巡检全部通过' : '⚠️ 铸渊 PSP 巡检发现问题,已记录'}`);
console.log('═══════════════════════════════════════════════════');
}
main().catch(err => {
console.error('❌ PSP 巡检致命错误:', err.message);
process.exit(1);
});