394 lines
15 KiB
JavaScript
394 lines
15 KiB
JavaScript
// 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);
|
||
});
|