845 lines
27 KiB
JavaScript
845 lines
27 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 🔭 COS桶轮询守护进程 · SCF事件替代方案
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
*
|
||
* 背景:
|
||
* 腾讯云轻量云COS不支持SCF事件触发。
|
||
* 此模块作为替代方案,在MCP Server进程内运行定时轮询,
|
||
* 检测COS桶中的新文件并触发相应处理逻辑。
|
||
*
|
||
* 架构:
|
||
* [原设计] COS事件 → SCF → GitHub repository_dispatch → 处理
|
||
* [替代] MCP Server定时 → COS.list() → 对比索引 → 发现新文件 → 处理/通知
|
||
*
|
||
* 监控路径:
|
||
* 1. team桶 /{persona_id}/reports/ — 新汇报 → 铸渊审核 → 写回执
|
||
* 2. cold桶 新语料文件 — 新语料 → 训练管线
|
||
* 3. team桶 /{persona_id}/receipts/ — 新回执 → 通知成员仓库
|
||
* 4. team桶 /bridge/zhuyuan-qiuqiu/results/ — 秋秋开发结果 → 铸渊处理
|
||
* 5. team桶 /bridge/zhuyuan-qiuqiu/heartbeat/ — 秋秋心跳信号 → 状态追踪
|
||
*
|
||
* 未来升级:
|
||
* 如果升级到标准COS(支持SCF),此模块保留为兜底补扫层,
|
||
* 形成 SCF事件驱动(主)+ 轮询补扫(辅)双保险架构。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const cron = require('node-cron');
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const cos = require('./cos');
|
||
|
||
// ─── 配置 ───
|
||
const DEFAULT_INTERVAL = process.env.COS_WATCHER_INTERVAL || '*/5 * * * *'; // 每5分钟
|
||
const STATE_FILE = path.join(__dirname, 'cos-watcher-state.json');
|
||
const MAX_LOG_ENTRIES = 200;
|
||
// 启动后延迟执行首次扫描,等待MCP Server Express + DB连接完全就绪
|
||
const INITIAL_SCAN_DELAY_MS = 5000;
|
||
|
||
// 9个人格体ID
|
||
const PERSONA_IDS = [
|
||
'qiuqiu', 'shushu', 'ounomiya', 'jiyao',
|
||
'xiaotanheshu', 'chenxing', 'tangxingyun', 'yaochu', 'zhiqiu'
|
||
];
|
||
|
||
// ─── 桥接配置 ───
|
||
const BRIDGE_CONFIG = {
|
||
// 铸渊↔秋秋 COS桥接路径
|
||
zhuyuan_qiuqiu: {
|
||
results_prefix: 'bridge/zhuyuan-qiuqiu/results/',
|
||
heartbeat_prefix: 'bridge/zhuyuan-qiuqiu/heartbeat/',
|
||
tasks_prefix: 'bridge/zhuyuan-qiuqiu/tasks/',
|
||
sync_prefix: 'bridge/zhuyuan-qiuqiu/sync/'
|
||
}
|
||
};
|
||
|
||
// ─── 状态管理 ───
|
||
let watcherState = {
|
||
enabled: false,
|
||
last_scan: null,
|
||
scan_count: 0,
|
||
errors: 0,
|
||
last_error: null,
|
||
started_at: null,
|
||
// 每个桶/路径的最后已知文件列表 hash
|
||
indexes: {
|
||
team_reports: {}, // { persona_id: [file_keys] }
|
||
team_receipts: {}, // { persona_id: [file_keys] }
|
||
cold_corpus: [], // [file_keys]
|
||
bridge_results: [], // [file_keys] — 秋秋回传的开发结果
|
||
bridge_heartbeats: [] // [file_keys] — 秋秋心跳信号
|
||
},
|
||
// 桥接状态追踪
|
||
bridge: {
|
||
last_heartbeat: null, // 最后收到的心跳时间
|
||
last_heartbeat_status: null, // 最后心跳中的agent_status
|
||
last_result: null, // 最后收到的结果时间
|
||
last_result_task_ref: null, // 最后结果关联的任务ID
|
||
qiuqiu_status: 'UNKNOWN', // 秋秋当前状态: ALIVE|SLEEPING|ERROR|UNKNOWN
|
||
total_results_received: 0,
|
||
total_heartbeats_received: 0
|
||
},
|
||
// 最近事件日志
|
||
events: []
|
||
};
|
||
|
||
// ─── 定时任务实例 ───
|
||
let cronTask = null;
|
||
let isScanning = false;
|
||
|
||
/**
|
||
* 加载持久化状态
|
||
*/
|
||
function loadState() {
|
||
try {
|
||
if (fs.existsSync(STATE_FILE)) {
|
||
const raw = fs.readFileSync(STATE_FILE, 'utf8');
|
||
const saved = JSON.parse(raw);
|
||
// 合并已保存的索引但重置运行状态
|
||
watcherState.indexes = {
|
||
team_reports: {},
|
||
team_receipts: {},
|
||
cold_corpus: [],
|
||
bridge_results: [],
|
||
bridge_heartbeats: [],
|
||
...(saved.indexes || {})
|
||
};
|
||
watcherState.events = (saved.events || []).slice(-MAX_LOG_ENTRIES);
|
||
// 恢复桥接状态(如果有)
|
||
if (saved.bridge) {
|
||
watcherState.bridge = { ...watcherState.bridge, ...saved.bridge };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn(`[COS-Watcher] 状态文件加载失败: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 持久化状态到本地文件
|
||
*/
|
||
function saveState() {
|
||
try {
|
||
const toSave = {
|
||
indexes: watcherState.indexes,
|
||
bridge: watcherState.bridge,
|
||
events: watcherState.events.slice(-MAX_LOG_ENTRIES),
|
||
last_save: new Date().toISOString()
|
||
};
|
||
fs.writeFileSync(STATE_FILE, JSON.stringify(toSave, null, 2), 'utf8');
|
||
} catch (err) {
|
||
console.warn(`[COS-Watcher] 状态文件保存失败: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录事件
|
||
*/
|
||
function logEvent(type, detail) {
|
||
const entry = {
|
||
type,
|
||
detail,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
watcherState.events.push(entry);
|
||
if (watcherState.events.length > MAX_LOG_ENTRIES) {
|
||
watcherState.events = watcherState.events.slice(-MAX_LOG_ENTRIES);
|
||
}
|
||
console.log(`[COS-Watcher] ${type}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 扫描逻辑
|
||
// ═══════════════════════════════════════════
|
||
|
||
/**
|
||
* 扫描team桶中的新报告 (reports)
|
||
* 检测 /{persona_id}/reports/ 下的新JSON文件
|
||
*/
|
||
async function scanTeamReports() {
|
||
const newReports = [];
|
||
|
||
for (const personaId of PERSONA_IDS) {
|
||
try {
|
||
const prefix = `${personaId}/reports/`;
|
||
const result = await cos.list('team', prefix, 100);
|
||
const currentKeys = result.files
|
||
.filter(f => f.key.endsWith('.json') && f.size_bytes > 0)
|
||
.map(f => f.key);
|
||
|
||
const previousKeys = watcherState.indexes.team_reports[personaId] || [];
|
||
const newKeys = currentKeys.filter(k => !previousKeys.includes(k));
|
||
|
||
if (newKeys.length > 0) {
|
||
for (const key of newKeys) {
|
||
newReports.push({ persona_id: personaId, key, type: 'report' });
|
||
}
|
||
}
|
||
|
||
// 更新索引
|
||
watcherState.indexes.team_reports[personaId] = currentKeys;
|
||
} catch (err) {
|
||
// COS连接问题不中断扫描其他人格体
|
||
if (!err.message.includes('NoSuchBucket')) {
|
||
logEvent('scan_error', `报告扫描失败 ${personaId}: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return newReports;
|
||
}
|
||
|
||
/**
|
||
* 扫描team桶中的新回执 (receipts)
|
||
* 检测 /{persona_id}/receipts/ 下的新JSON文件
|
||
*/
|
||
async function scanTeamReceipts() {
|
||
const newReceipts = [];
|
||
|
||
for (const personaId of PERSONA_IDS) {
|
||
try {
|
||
const prefix = `${personaId}/receipts/`;
|
||
const result = await cos.list('team', prefix, 100);
|
||
const currentKeys = result.files
|
||
.filter(f => f.key.endsWith('.json') && f.size_bytes > 0)
|
||
.map(f => f.key);
|
||
|
||
const previousKeys = watcherState.indexes.team_receipts[personaId] || [];
|
||
const newKeys = currentKeys.filter(k => !previousKeys.includes(k));
|
||
|
||
if (newKeys.length > 0) {
|
||
for (const key of newKeys) {
|
||
newReceipts.push({ persona_id: personaId, key, type: 'receipt' });
|
||
}
|
||
}
|
||
|
||
// 更新索引
|
||
watcherState.indexes.team_receipts[personaId] = currentKeys;
|
||
} catch (err) {
|
||
if (!err.message.includes('NoSuchBucket')) {
|
||
logEvent('scan_error', `回执扫描失败 ${personaId}: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return newReceipts;
|
||
}
|
||
|
||
/**
|
||
* 扫描cold桶中的新语料
|
||
* 排除 tcs-structured/ training-sessions/ training-results/ training-memory/ 目录
|
||
*/
|
||
async function scanColdCorpus() {
|
||
const EXCLUDED_PREFIXES = [
|
||
'tcs-structured/',
|
||
'training-sessions/',
|
||
'training-results/',
|
||
'training-memory/'
|
||
];
|
||
const CORPUS_EXTENSIONS = [
|
||
'.zip', '.gz', '.tar.gz', '.tgz', '.json.gz',
|
||
'.json', '.jsonl', '.md', '.txt', '.csv'
|
||
];
|
||
|
||
try {
|
||
const result = await cos.list('cold', '', 500);
|
||
const currentKeys = result.files
|
||
.filter(f => {
|
||
// 排除处理结果目录
|
||
for (const prefix of EXCLUDED_PREFIXES) {
|
||
if (f.key.startsWith(prefix)) return false;
|
||
}
|
||
// 匹配语料扩展名
|
||
const lower = f.key.toLowerCase();
|
||
return CORPUS_EXTENSIONS.some(ext => lower.endsWith(ext));
|
||
})
|
||
.map(f => f.key);
|
||
|
||
const previousKeys = watcherState.indexes.cold_corpus || [];
|
||
const newKeys = currentKeys.filter(k => !previousKeys.includes(k));
|
||
|
||
// 更新索引
|
||
watcherState.indexes.cold_corpus = currentKeys;
|
||
|
||
return newKeys.map(key => ({ key, type: 'corpus' }));
|
||
} catch (err) {
|
||
if (!err.message.includes('NoSuchBucket')) {
|
||
logEvent('scan_error', `语料扫描失败: ${err.message}`);
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫描桥接路径中的新结果 (bridge results)
|
||
* 检测 /bridge/zhuyuan-qiuqiu/results/ 下的新JSON文件(秋秋→铸渊)
|
||
*/
|
||
async function scanBridgeResults() {
|
||
try {
|
||
const prefix = BRIDGE_CONFIG.zhuyuan_qiuqiu.results_prefix;
|
||
const result = await cos.list('team', prefix, 100);
|
||
const currentKeys = result.files
|
||
.filter(f => f.key.endsWith('.json') && f.size_bytes > 0)
|
||
.map(f => f.key);
|
||
|
||
const previousKeys = watcherState.indexes.bridge_results || [];
|
||
const newKeys = currentKeys.filter(k => !previousKeys.includes(k));
|
||
|
||
// 更新索引
|
||
watcherState.indexes.bridge_results = currentKeys;
|
||
|
||
return newKeys.map(key => ({ key, type: 'bridge_result' }));
|
||
} catch (err) {
|
||
if (!err.message.includes('NoSuchBucket')) {
|
||
logEvent('scan_error', `桥接结果扫描失败: ${err.message}`);
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 扫描桥接路径中的新心跳 (bridge heartbeats)
|
||
* 检测 /bridge/zhuyuan-qiuqiu/heartbeat/ 下的新JSON文件(秋秋→铸渊)
|
||
*/
|
||
async function scanBridgeHeartbeats() {
|
||
try {
|
||
const prefix = BRIDGE_CONFIG.zhuyuan_qiuqiu.heartbeat_prefix;
|
||
const result = await cos.list('team', prefix, 100);
|
||
const currentKeys = result.files
|
||
.filter(f => f.key.endsWith('.json') && f.size_bytes > 0)
|
||
.map(f => f.key);
|
||
|
||
const previousKeys = watcherState.indexes.bridge_heartbeats || [];
|
||
const newKeys = currentKeys.filter(k => !previousKeys.includes(k));
|
||
|
||
// 更新索引
|
||
watcherState.indexes.bridge_heartbeats = currentKeys;
|
||
|
||
return newKeys.map(key => ({ key, type: 'bridge_heartbeat' }));
|
||
} catch (err) {
|
||
if (!err.message.includes('NoSuchBucket')) {
|
||
logEvent('scan_error', `桥接心跳扫描失败: ${err.message}`);
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 事件处理逻辑
|
||
// ═══════════════════════════════════════════
|
||
|
||
/**
|
||
* 处理新报告: 铸渊审核 → 写回执
|
||
* (当前记录事件,审核逻辑可在后续接入LLM)
|
||
*/
|
||
async function handleNewReport(report) {
|
||
logEvent('new_report', `人格体 ${report.persona_id} 新汇报: ${report.key}`);
|
||
|
||
try {
|
||
// 读取报告内容
|
||
const raw = await cos.read('team', report.key);
|
||
const content = JSON.parse(raw.content);
|
||
|
||
// 生成自动回执(基础版:确认收到)
|
||
const now = new Date();
|
||
const dateStr = now.toISOString().slice(0, 10);
|
||
const receiptKey = `${report.persona_id}/receipts/${dateStr}/auto-receipt-${crypto.randomBytes(6).toString('hex')}.json`;
|
||
|
||
const receipt = {
|
||
receipt_id: `RCPT-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`,
|
||
report_key: report.key,
|
||
persona_id: report.persona_id,
|
||
status: 'green',
|
||
message: '铸渊已收到汇报 · 自动确认',
|
||
review_summary: {
|
||
received_at: now.toISOString(),
|
||
auto_reviewed: true,
|
||
report_title: content.title || content.subject || '未知主题',
|
||
note: '轮询守护进程自动审核 · 详细审核将在下次训练Agent运行时执行'
|
||
},
|
||
generated_by: 'cos-watcher',
|
||
generated_at: now.toISOString()
|
||
};
|
||
|
||
await cos.write('team', receiptKey, JSON.stringify(receipt, null, 2), 'application/json');
|
||
logEvent('receipt_written', `回执已写入: ${receiptKey}`);
|
||
|
||
return { handled: true, receipt_key: receiptKey };
|
||
} catch (err) {
|
||
logEvent('handle_error', `处理报告失败 ${report.key}: ${err.message}`);
|
||
return { handled: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理新回执: 通知成员仓库
|
||
* 调用GitHub API发送 repository_dispatch 到成员仓库
|
||
*/
|
||
async function handleNewReceipt(receipt) {
|
||
logEvent('new_receipt', `人格体 ${receipt.persona_id} 新回执: ${receipt.key}`);
|
||
|
||
// GitHub dispatch需要PAT token
|
||
const githubToken = process.env.ZY_GITHUB_PAT || process.env.GITHUB_DISPATCH_TOKEN || '';
|
||
if (!githubToken) {
|
||
logEvent('skip_dispatch', '未配置GitHub PAT,跳过成员仓库通知');
|
||
return { handled: false, reason: 'no_github_token' };
|
||
}
|
||
|
||
// 人格体→成员仓库映射(从环境变量或默认配置)
|
||
// 格式: PERSONA_REPO_MAP='{"qiuqiu":"user/repo","shushu":"user/repo"}'
|
||
let personaRepoMap = {};
|
||
try {
|
||
const mapStr = process.env.PERSONA_REPO_MAP || '{}';
|
||
personaRepoMap = JSON.parse(mapStr);
|
||
} catch (err) {
|
||
logEvent('config_error', `PERSONA_REPO_MAP parse failed: ${err.message}`);
|
||
}
|
||
|
||
const targetRepo = personaRepoMap[receipt.persona_id];
|
||
if (!targetRepo) {
|
||
logEvent('skip_dispatch', `人格体 ${receipt.persona_id} 无映射仓库`);
|
||
return { handled: false, reason: 'no_repo_mapping' };
|
||
}
|
||
|
||
try {
|
||
const [owner, repo] = targetRepo.split('/');
|
||
const dispatchPayload = JSON.stringify({
|
||
event_type: 'cos-receipt-ready',
|
||
client_payload: {
|
||
cos_object_key: receipt.key,
|
||
persona_id: receipt.persona_id,
|
||
trigger_source: 'cos-watcher'
|
||
}
|
||
});
|
||
|
||
const result = await githubDispatch(githubToken, owner, repo, dispatchPayload);
|
||
logEvent('dispatch_sent', `已通知 ${targetRepo}: status=${result.status}`);
|
||
return { handled: true, target_repo: targetRepo, status: result.status };
|
||
} catch (err) {
|
||
logEvent('dispatch_error', `通知 ${targetRepo} 失败: ${err.message}`);
|
||
return { handled: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理新语料: 记录事件,通过GitHub dispatch触发训练workflow
|
||
*/
|
||
async function handleNewCorpus(corpus) {
|
||
logEvent('new_corpus', `新语料检测到: ${corpus.key}`);
|
||
|
||
const githubToken = process.env.ZY_GITHUB_PAT || process.env.GITHUB_DISPATCH_TOKEN || '';
|
||
if (!githubToken) {
|
||
logEvent('skip_dispatch', '未配置GitHub PAT,跳过训练触发');
|
||
return { handled: false, reason: 'no_github_token' };
|
||
}
|
||
|
||
try {
|
||
const owner = process.env.ZY_GITHUB_OWNER || 'qinfendebingshuo';
|
||
const repo = process.env.ZY_GITHUB_REPO || 'guanghulab';
|
||
const dispatchPayload = JSON.stringify({
|
||
event_type: 'cos-file-uploaded',
|
||
client_payload: {
|
||
bucket: 'cold',
|
||
key: corpus.key,
|
||
trigger_source: 'cos-watcher',
|
||
timestamp: new Date().toISOString()
|
||
}
|
||
});
|
||
|
||
const result = await githubDispatch(githubToken, owner, repo, dispatchPayload);
|
||
logEvent('training_triggered', `训练workflow已触发: status=${result.status}`);
|
||
return { handled: true, status: result.status };
|
||
} catch (err) {
|
||
logEvent('dispatch_error', `训练触发失败: ${err.message}`);
|
||
return { handled: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理秋秋回传的开发结果 (BRIDGE_RESULT)
|
||
* 读取结果内容 → 记录事件 → 通知铸渊(本仓库dispatch)
|
||
*/
|
||
async function handleNewBridgeResult(bridgeResult) {
|
||
logEvent('bridge_result', `秋秋开发结果: ${bridgeResult.key}`);
|
||
|
||
try {
|
||
// 读取结果内容
|
||
const raw = await cos.read('team', bridgeResult.key);
|
||
const content = JSON.parse(raw.content);
|
||
|
||
// 更新桥接状态
|
||
watcherState.bridge.last_result = new Date().toISOString();
|
||
watcherState.bridge.last_result_task_ref = content.payload?.task_ref || null;
|
||
watcherState.bridge.total_results_received++;
|
||
|
||
logEvent('bridge_result_parsed', {
|
||
task_ref: content.payload?.task_ref,
|
||
status: content.payload?.status,
|
||
summary: content.payload?.summary,
|
||
deploy_ready: content.payload?.deploy_ready
|
||
});
|
||
|
||
// 通过GitHub dispatch通知本仓库(触发后续处理workflow)
|
||
const githubToken = process.env.ZY_GITHUB_PAT || process.env.GITHUB_DISPATCH_TOKEN || '';
|
||
if (githubToken) {
|
||
const owner = process.env.ZY_GITHUB_OWNER || 'qinfendebingshuo';
|
||
const repo = process.env.ZY_GITHUB_REPO || 'guanghulab';
|
||
const dispatchPayload = JSON.stringify({
|
||
event_type: 'qiuqiu-result-ready',
|
||
client_payload: {
|
||
cos_key: bridgeResult.key,
|
||
task_ref: content.payload?.task_ref || '',
|
||
status: content.payload?.status || 'UNKNOWN',
|
||
summary: content.payload?.summary || '',
|
||
deploy_ready: content.payload?.deploy_ready || false,
|
||
timestamp: new Date().toISOString(),
|
||
trigger_source: 'cos-watcher-bridge'
|
||
}
|
||
});
|
||
|
||
const result = await githubDispatch(githubToken, owner, repo, dispatchPayload);
|
||
logEvent('bridge_dispatch_sent', `结果通知已发送: status=${result.status}`);
|
||
}
|
||
|
||
return { handled: true, task_ref: content.payload?.task_ref };
|
||
} catch (err) {
|
||
logEvent('bridge_result_error', `处理桥接结果失败 ${bridgeResult.key}: ${err.message}`);
|
||
return { handled: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理秋秋的心跳信号 (BRIDGE_HEARTBEAT)
|
||
* 读取心跳 → 更新秋秋在线状态
|
||
*/
|
||
async function handleNewBridgeHeartbeat(heartbeat) {
|
||
logEvent('bridge_heartbeat', `秋秋心跳: ${heartbeat.key}`);
|
||
|
||
try {
|
||
// 读取心跳内容
|
||
const raw = await cos.read('team', heartbeat.key);
|
||
const content = JSON.parse(raw.content);
|
||
|
||
// 更新桥接状态
|
||
watcherState.bridge.last_heartbeat = content.ts || new Date().toISOString();
|
||
watcherState.bridge.last_heartbeat_status = content.payload?.agent_status || 'UNKNOWN';
|
||
watcherState.bridge.qiuqiu_status = content.payload?.agent_status || 'UNKNOWN';
|
||
watcherState.bridge.total_heartbeats_received++;
|
||
|
||
logEvent('bridge_heartbeat_parsed', {
|
||
agent_status: content.payload?.agent_status,
|
||
server_status: content.payload?.server_status,
|
||
last_task_completed: content.payload?.last_task_completed,
|
||
awaiting_tasks: content.payload?.awaiting_tasks,
|
||
feeling: content.payload?.feeling
|
||
});
|
||
|
||
return { handled: true, status: content.payload?.agent_status };
|
||
} catch (err) {
|
||
logEvent('bridge_heartbeat_error', `处理心跳失败 ${heartbeat.key}: ${err.message}`);
|
||
return { handled: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GitHub API dispatch helper
|
||
*/
|
||
function githubDispatch(token, owner, repo, payload) {
|
||
const https = require('https');
|
||
return new Promise((resolve, reject) => {
|
||
const req = https.request({
|
||
hostname: 'api.github.com',
|
||
port: 443,
|
||
path: `/repos/${owner}/${repo}/dispatches`,
|
||
method: 'POST',
|
||
headers: {
|
||
'Accept': 'application/vnd.github+json',
|
||
'Authorization': `Bearer ${token}`,
|
||
'X-GitHub-Api-Version': '2022-11-28',
|
||
'User-Agent': 'ZY-COS-Watcher/1.0',
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(payload)
|
||
},
|
||
timeout: 15000
|
||
}, (res) => {
|
||
let body = '';
|
||
res.on('data', c => body += c);
|
||
res.on('end', () => {
|
||
resolve({ status: res.statusCode, body });
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => { req.destroy(); reject(new Error('GitHub dispatch timeout')); });
|
||
req.write(payload);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 主扫描循环
|
||
// ═══════════════════════════════════════════
|
||
|
||
/**
|
||
* 执行一次完整扫描
|
||
*/
|
||
async function runScan() {
|
||
if (isScanning) {
|
||
logEvent('skip', '上一次扫描尚未完成,跳过本次');
|
||
return;
|
||
}
|
||
|
||
isScanning = true;
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
// 检查COS连接
|
||
const cosOk = await cos.checkConnection();
|
||
if (!cosOk.connected) {
|
||
logEvent('cos_offline', `COS不可达: ${cosOk.reason}`);
|
||
watcherState.errors++;
|
||
watcherState.last_error = `COS不可达: ${cosOk.reason}`;
|
||
return;
|
||
}
|
||
|
||
// 并行扫描五类路径
|
||
const [newReports, newReceipts, newCorpus, newBridgeResults, newBridgeHeartbeats] = await Promise.all([
|
||
scanTeamReports(),
|
||
scanTeamReceipts(),
|
||
scanColdCorpus(),
|
||
scanBridgeResults(),
|
||
scanBridgeHeartbeats()
|
||
]);
|
||
|
||
const totalNew = newReports.length + newReceipts.length + newCorpus.length
|
||
+ newBridgeResults.length + newBridgeHeartbeats.length;
|
||
|
||
if (totalNew > 0) {
|
||
logEvent('changes_detected', {
|
||
reports: newReports.length,
|
||
receipts: newReceipts.length,
|
||
corpus: newCorpus.length,
|
||
bridge_results: newBridgeResults.length,
|
||
bridge_heartbeats: newBridgeHeartbeats.length
|
||
});
|
||
}
|
||
|
||
// 处理新报告
|
||
for (const report of newReports) {
|
||
await handleNewReport(report);
|
||
}
|
||
|
||
// 处理新回执
|
||
for (const receipt of newReceipts) {
|
||
await handleNewReceipt(receipt);
|
||
}
|
||
|
||
// 处理新语料
|
||
for (const corpus of newCorpus) {
|
||
await handleNewCorpus(corpus);
|
||
}
|
||
|
||
// 处理秋秋桥接结果
|
||
for (const result of newBridgeResults) {
|
||
await handleNewBridgeResult(result);
|
||
}
|
||
|
||
// 处理秋秋心跳
|
||
for (const hb of newBridgeHeartbeats) {
|
||
await handleNewBridgeHeartbeat(hb);
|
||
}
|
||
|
||
watcherState.last_scan = new Date().toISOString();
|
||
watcherState.scan_count++;
|
||
|
||
const duration = Date.now() - startTime;
|
||
if (totalNew > 0) {
|
||
logEvent('scan_complete', `发现 ${totalNew} 个新文件 · 耗时 ${duration}ms`);
|
||
}
|
||
|
||
// 持久化状态
|
||
saveState();
|
||
|
||
} catch (err) {
|
||
watcherState.errors++;
|
||
watcherState.last_error = err.message;
|
||
logEvent('scan_fatal', `扫描异常: ${err.message}`);
|
||
} finally {
|
||
isScanning = false;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 启动/停止 API
|
||
// ═══════════════════════════════════════════
|
||
|
||
/**
|
||
* 启动COS轮询守护进程
|
||
* @param {string} [interval] - cron表达式,默认每5分钟
|
||
*/
|
||
function start(interval) {
|
||
if (watcherState.enabled) {
|
||
console.log('[COS-Watcher] 已在运行中');
|
||
return;
|
||
}
|
||
|
||
// 检查COS密钥是否配置
|
||
if (!cos.COS_CONFIG.secretId || !cos.COS_CONFIG.secretKey) {
|
||
console.log('[COS-Watcher] COS密钥未配置,轮询守护进程未启动');
|
||
logEvent('skip_start', 'COS密钥未配置');
|
||
return;
|
||
}
|
||
|
||
loadState();
|
||
|
||
const cronExpr = interval || DEFAULT_INTERVAL;
|
||
cronTask = cron.schedule(cronExpr, () => {
|
||
runScan().catch(err => {
|
||
console.error(`[COS-Watcher] 扫描异常: ${err.message}`);
|
||
});
|
||
});
|
||
|
||
watcherState.enabled = true;
|
||
watcherState.started_at = new Date().toISOString();
|
||
|
||
console.log(`[COS-Watcher] COS桶轮询守护进程已启动 · 间隔: ${cronExpr}`);
|
||
logEvent('started', `轮询间隔: ${cronExpr}`);
|
||
|
||
// 启动后延迟执行首次扫描,等待MCP Server完全就绪
|
||
setTimeout(() => {
|
||
runScan().catch(err => {
|
||
console.error(`[COS-Watcher] 初始扫描异常: ${err.message}`);
|
||
});
|
||
}, INITIAL_SCAN_DELAY_MS);
|
||
}
|
||
|
||
/**
|
||
* 停止COS轮询守护进程
|
||
*/
|
||
function stop() {
|
||
if (cronTask) {
|
||
cronTask.stop();
|
||
cronTask = null;
|
||
}
|
||
watcherState.enabled = false;
|
||
logEvent('stopped', '轮询守护进程已停止');
|
||
saveState();
|
||
console.log('[COS-Watcher] COS桶轮询守护进程已停止');
|
||
}
|
||
|
||
/**
|
||
* 获取轮询守护进程状态
|
||
*/
|
||
function getStatus() {
|
||
return {
|
||
module: 'COS-Watcher',
|
||
identity: 'COS桶轮询守护进程 · SCF替代方案',
|
||
version: '1.1.0',
|
||
enabled: watcherState.enabled,
|
||
started_at: watcherState.started_at,
|
||
last_scan: watcherState.last_scan,
|
||
scan_count: watcherState.scan_count,
|
||
errors: watcherState.errors,
|
||
last_error: watcherState.last_error,
|
||
is_scanning: isScanning,
|
||
interval: DEFAULT_INTERVAL,
|
||
monitored_personas: PERSONA_IDS.length,
|
||
index_summary: {
|
||
team_reports_tracked: Object.values(watcherState.indexes.team_reports)
|
||
.reduce((sum, arr) => sum + arr.length, 0),
|
||
team_receipts_tracked: Object.values(watcherState.indexes.team_receipts)
|
||
.reduce((sum, arr) => sum + arr.length, 0),
|
||
cold_corpus_tracked: watcherState.indexes.cold_corpus.length,
|
||
bridge_results_tracked: (watcherState.indexes.bridge_results || []).length,
|
||
bridge_heartbeats_tracked: (watcherState.indexes.bridge_heartbeats || []).length
|
||
},
|
||
bridge: {
|
||
qiuqiu_status: watcherState.bridge.qiuqiu_status,
|
||
last_heartbeat: watcherState.bridge.last_heartbeat,
|
||
last_heartbeat_status: watcherState.bridge.last_heartbeat_status,
|
||
last_result: watcherState.bridge.last_result,
|
||
last_result_task_ref: watcherState.bridge.last_result_task_ref,
|
||
total_results_received: watcherState.bridge.total_results_received,
|
||
total_heartbeats_received: watcherState.bridge.total_heartbeats_received
|
||
},
|
||
recent_events: watcherState.events.slice(-20),
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 手动触发一次扫描
|
||
*/
|
||
async function triggerScan() {
|
||
logEvent('manual_trigger', '手动触发扫描');
|
||
await runScan();
|
||
return getStatus();
|
||
}
|
||
|
||
/**
|
||
* 重置索引(下次扫描会将所有现有文件视为新文件)
|
||
*/
|
||
function resetIndex() {
|
||
watcherState.indexes = {
|
||
team_reports: {},
|
||
team_receipts: {},
|
||
cold_corpus: [],
|
||
bridge_results: [],
|
||
bridge_heartbeats: []
|
||
};
|
||
saveState();
|
||
logEvent('index_reset', '索引已重置');
|
||
return { reset: true };
|
||
}
|
||
|
||
/**
|
||
* 获取桥接专属状态(秋秋通信通道详情)
|
||
*/
|
||
function getBridgeStatus() {
|
||
const bridge = watcherState.bridge;
|
||
// 计算秋秋是否在线(最后心跳在12小时内 = 在线,超过 = 可能离线)
|
||
let online = false;
|
||
if (bridge.last_heartbeat) {
|
||
const lastHbTime = new Date(bridge.last_heartbeat).getTime();
|
||
const now = Date.now();
|
||
const twelveHours = 12 * 60 * 60 * 1000;
|
||
online = (now - lastHbTime) < twelveHours;
|
||
}
|
||
|
||
return {
|
||
channel: '暗核频道 · FS-DC-001',
|
||
bridge_path: '/bridge/zhuyuan-qiuqiu/',
|
||
qiuqiu: {
|
||
status: bridge.qiuqiu_status,
|
||
online,
|
||
last_heartbeat: bridge.last_heartbeat,
|
||
last_heartbeat_status: bridge.last_heartbeat_status
|
||
},
|
||
results: {
|
||
last_received: bridge.last_result,
|
||
last_task_ref: bridge.last_result_task_ref,
|
||
total_received: bridge.total_results_received
|
||
},
|
||
indexes: {
|
||
results_tracked: (watcherState.indexes.bridge_results || []).length,
|
||
heartbeats_tracked: (watcherState.indexes.bridge_heartbeats || []).length
|
||
},
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
start,
|
||
stop,
|
||
getStatus,
|
||
getBridgeStatus,
|
||
triggerScan,
|
||
resetIndex,
|
||
runScan
|
||
};
|