309 lines
8.6 KiB
JavaScript
309 lines
8.6 KiB
JavaScript
|
|
/**
|
|||
|
|
* buffer/scripts/flusher.js
|
|||
|
|
*
|
|||
|
|
* 批量写入脚本:staging → grid-db
|
|||
|
|
* 读取 buffer/staging/ 中的 batch 文件,校验后写入 grid-db/
|
|||
|
|
*
|
|||
|
|
* 运行时机:每天 21:30 CST(由 buffer-flush.yml 触发)
|
|||
|
|
* 也可通过 repository_dispatch: grid-db-flush 手动触发
|
|||
|
|
*
|
|||
|
|
* 守护: PER-ZY001 铸渊
|
|||
|
|
* 系统: SYS-GLW-0001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const BUFFER_ROOT = path.join(__dirname, '..');
|
|||
|
|
const STAGING = path.join(BUFFER_ROOT, 'staging');
|
|||
|
|
const PROCESSED = path.join(BUFFER_ROOT, 'processed');
|
|||
|
|
const REPO_ROOT = path.join(__dirname, '../..');
|
|||
|
|
const GRID_DB = path.join(REPO_ROOT, 'grid-db');
|
|||
|
|
|
|||
|
|
// 加载 dev-module-map 用于校验
|
|||
|
|
const DEV_MAP_PATH = path.join(GRID_DB, 'rules', 'dev-module-map.json');
|
|||
|
|
|
|||
|
|
function getDateStr() {
|
|||
|
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getTimestamp() {
|
|||
|
|
return new Date().toISOString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 核心铁律:验证写入目标必须在 grid-db/ 内
|
|||
|
|
* 并且确保来源只能从 buffer/staging/ 发起
|
|||
|
|
*/
|
|||
|
|
function validateFlushPath(targetPath) {
|
|||
|
|
const resolved = path.resolve(targetPath);
|
|||
|
|
const gridDbResolved = path.resolve(GRID_DB);
|
|||
|
|
|
|||
|
|
if (!resolved.startsWith(gridDbResolved)) {
|
|||
|
|
throw new Error(
|
|||
|
|
`[BLOCKED] Flush target must be within grid-db/. Got: ${resolved}`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 铁律检查:禁止非 flusher 路径直写 grid-db
|
|||
|
|
* 此函数用于外部脚本调用以校验
|
|||
|
|
*/
|
|||
|
|
function assertBufferOnly(callerPath) {
|
|||
|
|
const resolved = path.resolve(callerPath);
|
|||
|
|
const bufferResolved = path.resolve(BUFFER_ROOT);
|
|||
|
|
|
|||
|
|
if (!resolved.startsWith(bufferResolved)) {
|
|||
|
|
throw new Error(
|
|||
|
|
'[BLOCKED] 违反铁律:禁止直接写入 grid-db/。' +
|
|||
|
|
'所有写入必须经过 buffer/ 缓冲层。' +
|
|||
|
|
` 调用者: ${resolved}`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 校验单条消息
|
|||
|
|
*/
|
|||
|
|
function validateMessage(msg) {
|
|||
|
|
const errors = [];
|
|||
|
|
|
|||
|
|
if (!msg.message_id) {
|
|||
|
|
errors.push('Missing message_id');
|
|||
|
|
}
|
|||
|
|
if (!msg.dev_id || !/^DEV-\d{3}$/.test(msg.dev_id)) {
|
|||
|
|
errors.push(`Invalid dev_id: ${msg.dev_id}`);
|
|||
|
|
}
|
|||
|
|
if (!msg.source) {
|
|||
|
|
errors.push('Missing source');
|
|||
|
|
}
|
|||
|
|
if (!msg.type) {
|
|||
|
|
errors.push('Missing type');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return errors;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 校验 dev_id 是否在映射表中
|
|||
|
|
*/
|
|||
|
|
function validateDevId(devId, devModuleMap) {
|
|||
|
|
return devModuleMap.mappings && devModuleMap.mappings[devId];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将消息写入 grid-db/inbox/ 供 process-inbox.js 处理
|
|||
|
|
* 或直接写入对应目录(根据消息类型)
|
|||
|
|
*/
|
|||
|
|
function flushMessageToGridDb(msg) {
|
|||
|
|
// 对于 checkin 类型,写入 memory
|
|||
|
|
if (msg.type === 'checkin') {
|
|||
|
|
const memoryDir = path.join(GRID_DB, 'memory', msg.dev_id);
|
|||
|
|
validateFlushPath(memoryDir);
|
|||
|
|
|
|||
|
|
if (!fs.existsSync(memoryDir)) {
|
|||
|
|
fs.mkdirSync(memoryDir, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新签到日志
|
|||
|
|
const checkinLogPath = path.join(memoryDir, 'checkin-log.json');
|
|||
|
|
let checkinLog = { records: [] };
|
|||
|
|
if (fs.existsSync(checkinLogPath)) {
|
|||
|
|
try {
|
|||
|
|
checkinLog = JSON.parse(fs.readFileSync(checkinLogPath, 'utf8'));
|
|||
|
|
} catch (e) {
|
|||
|
|
checkinLog = { records: [] };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
checkinLog.records.push({
|
|||
|
|
timestamp: msg.created_at || getTimestamp(),
|
|||
|
|
source: msg.source,
|
|||
|
|
payload: msg.payload,
|
|||
|
|
buffer_message_id: msg.message_id
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fs.writeFileSync(checkinLogPath, JSON.stringify(checkinLog, null, 2) + '\n');
|
|||
|
|
return 'memory';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 对于其他类型,写入 grid-db/inbox/ 供 process-inbox.js 处理
|
|||
|
|
const inboxDir = path.join(GRID_DB, 'inbox');
|
|||
|
|
validateFlushPath(inboxDir);
|
|||
|
|
|
|||
|
|
if (!fs.existsSync(inboxDir)) {
|
|||
|
|
fs.mkdirSync(inboxDir, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 转换为 grid-db inbox 格式
|
|||
|
|
const gridDbMsg = {
|
|||
|
|
schema_version: '1.0',
|
|||
|
|
message_id: `${getDateStr()}-${msg.dev_id}-${msg.type}`,
|
|||
|
|
dev_id: msg.dev_id,
|
|||
|
|
persona_id: msg.payload.persona_id || 'unknown',
|
|||
|
|
type: mapBufferTypeToGridDbType(msg.type),
|
|||
|
|
timestamp: msg.created_at || getTimestamp(),
|
|||
|
|
payload: msg.payload,
|
|||
|
|
source_buffer_id: msg.message_id
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const filename = `${gridDbMsg.message_id}.json`;
|
|||
|
|
const filePath = path.join(inboxDir, filename);
|
|||
|
|
fs.writeFileSync(filePath, JSON.stringify(gridDbMsg, null, 2) + '\n');
|
|||
|
|
return 'inbox';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 映射 buffer 消息类型到 grid-db 消息类型
|
|||
|
|
*/
|
|||
|
|
function mapBufferTypeToGridDbType(bufferType) {
|
|||
|
|
const typeMap = {
|
|||
|
|
'interaction': 'interaction-dump',
|
|||
|
|
'task': 'progress-update',
|
|||
|
|
'feedback': 'dev-log',
|
|||
|
|
'checkin': 'checkin',
|
|||
|
|
'broadcast_request': 'progress-update'
|
|||
|
|
};
|
|||
|
|
return typeMap[bufferType] || 'dev-log';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 移动 batch 到 processed 目录
|
|||
|
|
*/
|
|||
|
|
function archiveBatch(batchPath, dateStr) {
|
|||
|
|
const processedDir = path.join(PROCESSED, dateStr);
|
|||
|
|
if (!fs.existsSync(processedDir)) {
|
|||
|
|
fs.mkdirSync(processedDir, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filename = path.basename(batchPath);
|
|||
|
|
const destPath = path.join(processedDir, filename);
|
|||
|
|
fs.renameSync(batchPath, destPath);
|
|||
|
|
return destPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 清理过期的 processed 数据
|
|||
|
|
*/
|
|||
|
|
function cleanupProcessed(keepDays) {
|
|||
|
|
if (!fs.existsSync(PROCESSED)) return;
|
|||
|
|
|
|||
|
|
const now = new Date();
|
|||
|
|
const dirs = fs.readdirSync(PROCESSED)
|
|||
|
|
.filter(d => fs.statSync(path.join(PROCESSED, d)).isDirectory());
|
|||
|
|
|
|||
|
|
for (const dir of dirs) {
|
|||
|
|
// 目录名格式: YYYYMMDD — 必须是 8 位数字
|
|||
|
|
if (!/^\d{8}$/.test(dir)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const year = parseInt(dir.substring(0, 4));
|
|||
|
|
const month = parseInt(dir.substring(4, 6)) - 1;
|
|||
|
|
const day = parseInt(dir.substring(6, 8));
|
|||
|
|
const dirDate = new Date(year, month, day);
|
|||
|
|
|
|||
|
|
const diffDays = (now - dirDate) / (1000 * 60 * 60 * 24);
|
|||
|
|
if (diffDays > keepDays) {
|
|||
|
|
const dirPath = path.join(PROCESSED, dir);
|
|||
|
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
|||
|
|
console.log(`[flusher] Cleaned up expired processed dir: ${dir}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
console.log(`[flusher] === Buffer Flusher Started === ${getTimestamp()}`);
|
|||
|
|
|
|||
|
|
// 加载 dev-module-map
|
|||
|
|
let devModuleMap = { mappings: {} };
|
|||
|
|
if (fs.existsSync(DEV_MAP_PATH)) {
|
|||
|
|
devModuleMap = JSON.parse(fs.readFileSync(DEV_MAP_PATH, 'utf8'));
|
|||
|
|
} else {
|
|||
|
|
console.warn('[flusher] dev-module-map.json not found, skipping dev_id validation');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dateStr = getDateStr();
|
|||
|
|
let totalFlushed = 0;
|
|||
|
|
let totalFailed = 0;
|
|||
|
|
|
|||
|
|
// 扫描 staging 中的所有子目录
|
|||
|
|
if (!fs.existsSync(STAGING)) {
|
|||
|
|
console.log('[flusher] staging/ does not exist, nothing to flush');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const subdirs = fs.readdirSync(STAGING)
|
|||
|
|
.filter(d => fs.statSync(path.join(STAGING, d)).isDirectory());
|
|||
|
|
|
|||
|
|
for (const subdir of subdirs) {
|
|||
|
|
const stagingDir = path.join(STAGING, subdir);
|
|||
|
|
const batchFiles = fs.readdirSync(stagingDir)
|
|||
|
|
.filter(f => f.startsWith('batch-') && f.endsWith('.json'));
|
|||
|
|
|
|||
|
|
for (const batchFile of batchFiles) {
|
|||
|
|
const batchPath = path.join(stagingDir, batchFile);
|
|||
|
|
let batch;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
batch = JSON.parse(fs.readFileSync(batchPath, 'utf8'));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`[flusher] Failed to parse ${batchFile}: ${err.message}`);
|
|||
|
|
totalFailed++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 校验 dev_id
|
|||
|
|
if (batch.dev_id && batch.dev_id !== 'system') {
|
|||
|
|
if (!validateDevId(batch.dev_id, devModuleMap)) {
|
|||
|
|
console.warn(`[flusher] Unknown dev_id ${batch.dev_id} in ${batchFile}, processing anyway`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理 batch 中的每条消息
|
|||
|
|
let batchFlushed = 0;
|
|||
|
|
for (const msg of (batch.messages || [])) {
|
|||
|
|
const errors = validateMessage(msg);
|
|||
|
|
if (errors.length > 0) {
|
|||
|
|
console.error(`[flusher] Validation failed for ${msg.message_id}: ${errors.join(', ')}`);
|
|||
|
|
msg.status = 'failed';
|
|||
|
|
totalFailed++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const target = flushMessageToGridDb(msg);
|
|||
|
|
msg.status = 'flushed';
|
|||
|
|
batchFlushed++;
|
|||
|
|
console.log(`[flusher] Flushed ${msg.message_id} → ${target}`);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`[flusher] Error flushing ${msg.message_id}: ${err.message}`);
|
|||
|
|
msg.status = 'failed';
|
|||
|
|
totalFailed++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
totalFlushed += batchFlushed;
|
|||
|
|
|
|||
|
|
// 归档 batch
|
|||
|
|
try {
|
|||
|
|
archiveBatch(batchPath, dateStr);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error(`[flusher] Error archiving ${batchFile}: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理过期的 processed 数据(保留 7 天)
|
|||
|
|
cleanupProcessed(7);
|
|||
|
|
|
|||
|
|
console.log(`[flusher] === Complete: ${totalFlushed} flushed, ${totalFailed} failed ===`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { validateFlushPath, assertBufferOnly, validateMessage, flushMessageToGridDb };
|
|||
|
|
|
|||
|
|
if (require.main === module) {
|
|||
|
|
main();
|
|||
|
|
}
|