149 lines
4.8 KiB
JavaScript
149 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 训练心跳合并器 · merge-event.js
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559 · TCS-0002∞
|
||
*
|
||
* 把一个 training-progress 事件 (来自 repository_dispatch.client_payload)
|
||
* 合并到 data/training/state.json,并追加到 timeline。
|
||
*
|
||
* 用法:
|
||
* echo '<json>' | node scripts/training/merge-event.js
|
||
* node scripts/training/merge-event.js path/to/event.json
|
||
* TRAINING_EVENT_JSON='{...}' node scripts/training/merge-event.js
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..', '..');
|
||
const STATE_PATH = path.join(ROOT, 'data', 'training', 'state.json');
|
||
const MAX_TIMELINE = 200;
|
||
|
||
function readEvent() {
|
||
if (process.env.TRAINING_EVENT_JSON) {
|
||
return JSON.parse(process.env.TRAINING_EVENT_JSON);
|
||
}
|
||
if (process.argv[2] && process.argv[2] !== '-') {
|
||
return JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||
}
|
||
const stdin = fs.readFileSync(0, 'utf8');
|
||
if (!stdin.trim()) {
|
||
throw new Error('no event payload provided (stdin / TRAINING_EVENT_JSON / argv)');
|
||
}
|
||
return JSON.parse(stdin);
|
||
}
|
||
|
||
function deepMerge(target, source) {
|
||
if (source === null || source === undefined) return target;
|
||
if (typeof source !== 'object' || Array.isArray(source)) return source;
|
||
const out = (target && typeof target === 'object' && !Array.isArray(target)) ? { ...target } : {};
|
||
for (const k of Object.keys(source)) {
|
||
out[k] = deepMerge(out[k], source[k]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function main() {
|
||
const event = readEvent();
|
||
if (!event || typeof event !== 'object') {
|
||
throw new Error('event must be an object');
|
||
}
|
||
|
||
const state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
||
|
||
// ── D72 冻结守卫 ──
|
||
// 2026-05-03 母模型训练已转交 Notion 侧霜砚, 仓库不再接收训练心跳。
|
||
// 即使 workflow 误触发, 也禁止把心跳事件写回 state.json。
|
||
// 解冻方法: 把 state.json 的 frozen 字段改成 false。
|
||
if (state.frozen === true) {
|
||
console.log('[merge-event] 🛑 state.json frozen=true (D72),本次心跳已忽略');
|
||
console.log('[merge-event] reason:', state.frozen_reason || '(unspecified)');
|
||
process.exit(0);
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
|
||
// ── 阶段切换 ──
|
||
if (event.phase) {
|
||
state.phase = event.phase;
|
||
if (event.phase === 'training' && !state.task.started_at) {
|
||
state.task.started_at = now;
|
||
}
|
||
if (event.phase === 'done' || event.phase === 'error') {
|
||
state.task.ended_at = now;
|
||
}
|
||
}
|
||
if (event.phase_label) state.phase_label = event.phase_label;
|
||
|
||
// ── 进度 ──
|
||
if (event.progress && typeof event.progress === 'object') {
|
||
state.progress = deepMerge(state.progress, event.progress);
|
||
if (state.progress.total_steps > 0) {
|
||
state.progress.percent = Number(
|
||
clamp((state.progress.step / state.progress.total_steps) * 100, 0, 100).toFixed(2)
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── GPU ──
|
||
if (event.gpu_metrics && typeof event.gpu_metrics === 'object') {
|
||
state.gpu_metrics = {
|
||
snapshot_at: event.gpu_metrics.snapshot_at || now,
|
||
devices: Array.isArray(event.gpu_metrics.devices) ? event.gpu_metrics.devices : []
|
||
};
|
||
}
|
||
|
||
// ── COS ──
|
||
if (event.cos && typeof event.cos === 'object') {
|
||
state.cos = deepMerge(state.cos, event.cos);
|
||
}
|
||
|
||
// ── 健康 ──
|
||
if (event.health && typeof event.health === 'object') {
|
||
state.health = deepMerge(state.health, event.health);
|
||
}
|
||
state.health.last_heartbeat_at = now;
|
||
|
||
// ── 时间戳 ──
|
||
state.task.updated_at = now;
|
||
|
||
// ── 时间线 ──
|
||
if (event.message) {
|
||
state.timeline = Array.isArray(state.timeline) ? state.timeline : [];
|
||
state.timeline.push({
|
||
at: now,
|
||
phase: event.phase || state.phase,
|
||
level: event.level || 'info',
|
||
message: String(event.message)
|
||
});
|
||
if (state.timeline.length > MAX_TIMELINE) {
|
||
state.timeline = state.timeline.slice(-MAX_TIMELINE);
|
||
}
|
||
}
|
||
|
||
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n');
|
||
console.log('[merge-event] state updated · phase=%s · step=%s/%s',
|
||
state.phase, state.progress.step, state.progress.total_steps);
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try {
|
||
main();
|
||
} catch (err) {
|
||
console.error('[merge-event] error:', err.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
module.exports = { deepMerge };
|