Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
修复巡检报告快照可能多次生成的竞态: 1. reportScheduler._inProgress —— 快照执行 >60s 时, 下一个 tick 进来直接跳过,防止定时 tick 重入 2. index.cjs._isSnapshotRunning 双向互斥 —— reportScheduler.tick 和 IPC 手动触发路径互相感知, 谁先进来谁锁,另一条路径跳过 3. IPC inspection:runReportSnapshot 入口拦截 —— 用户连点"立即巡检"按钮被拒绝 之前已修:renderer scheduler:start 不再触发 scanAllNow。
170 lines
6.5 KiB
JavaScript
170 lines
6.5 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* 巡检报告定时调度器
|
||
*
|
||
* 和 inspectionScheduler.cjs **物理隔离**:
|
||
* - 独立 tick 循环(1min)
|
||
* - 独立配置存储(reportScheduleStore)
|
||
* - 独立触发路径(调 index.cjs 里 runReportSnapshot 的实现函数)
|
||
* - 独立日报告聚合(dailyReportStore)
|
||
*
|
||
* 防止重蹈「两个调度器竞争同一巡检任务」的覆辙。
|
||
*/
|
||
|
||
const reportScheduleStore = require("./reportScheduleStore.cjs");
|
||
const reportStore = require("./reportStore.cjs");
|
||
const dailyReportStore = require("./dailyReportStore.cjs");
|
||
|
||
let _tickTimer = null;
|
||
let _dailyTimer = null;
|
||
let _lastDailyTriggerKey = null; // 用于 HH:MM 日报告每日只触发一次
|
||
let _inProgress = false; // 快照执行中锁 —— 防止执行时间 >60s 时被下一个 tick 重入
|
||
|
||
/**
|
||
* 启动 reportScheduler
|
||
*
|
||
* @param {object} deps
|
||
* @param {function} deps.readStorage
|
||
* @param {function} deps.writeStorage
|
||
* @param {function} deps.runSnapshotFn - 定时快照执行函数(renderer 侧 Hook 实际负责执行,这里传空函数)
|
||
* @param {function} [deps.onDailyReportReady] - 日报告聚合完成后回调 async (report) => void
|
||
*/
|
||
function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, getAllDeviceIdsFn }) {
|
||
if (_tickTimer) return; // 已启动
|
||
|
||
const readFn = (k) => readStorage(k);
|
||
const writeFn = (k, v) => writeStorage(k, v);
|
||
const _getAllDeviceIds = getAllDeviceIdsFn;
|
||
|
||
const tick = async () => {
|
||
const cfg = reportScheduleStore.getSchedule(readFn);
|
||
if (!cfg.enabled) return;
|
||
|
||
// 防重入:快照正在执行中就跳过(执行时间 >60s 时下一个 tick 会撞到)
|
||
if (_inProgress) {
|
||
console.log("[reportScheduler] tick: 上一个快照还在执行,跳过");
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const intervalMs = (cfg.defaultIntervalHours || 1) * 3600 * 1000;
|
||
const lastRun = cfg.lastRunAt || 0;
|
||
const sinceLast = now - lastRun;
|
||
|
||
// 还没到间隔就跳过
|
||
if (lastRun > 0 && sinceLast < intervalMs) {
|
||
const remainingMin = ((intervalMs - sinceLast) / 60000).toFixed(1);
|
||
console.log(`[reportScheduler] tick: 未到间隔,还需 ${remainingMin} 分钟`);
|
||
return;
|
||
}
|
||
|
||
// null/undefined = 全部;空数组 = 不巡检;非空数组 = 指定设备
|
||
let deviceIds = cfg.enabledDeviceIds;
|
||
if (deviceIds === null || deviceIds === undefined) {
|
||
// 全部设备 —— 通过回调拿(由调用方注入,避免依赖不存在的 deviceStore.listDevices)
|
||
if (typeof _getAllDeviceIds === "function") {
|
||
deviceIds = _getAllDeviceIds();
|
||
} else {
|
||
console.error("[reportScheduler] ❌ 无 getAllDeviceIds 回调,跳过");
|
||
return;
|
||
}
|
||
} else if (Array.isArray(deviceIds) && deviceIds.length === 0) {
|
||
console.log("[reportScheduler] tick: enabledDeviceIds=[], 跳过(不巡检任何设备)");
|
||
return;
|
||
}
|
||
|
||
// 过滤掉 isDeviceEnabled=false 的(disabled 状态)
|
||
const enabledDeviceIds = (deviceIds || []).filter(id =>
|
||
reportScheduleStore.isDeviceEnabled(readFn, id)
|
||
);
|
||
|
||
if (enabledDeviceIds.length === 0) {
|
||
console.log("[reportScheduler] tick: 0 台设备启用,跳过");
|
||
return;
|
||
}
|
||
|
||
console.log(`[reportScheduler] tick: 到点,${enabledDeviceIds.length} 台设备,interval=${cfg.defaultIntervalHours}h,距上次 ${(sinceLast/60000).toFixed(1)} 分钟`);
|
||
_inProgress = true;
|
||
try {
|
||
await runSnapshotFn(enabledDeviceIds, "scheduled");
|
||
// 持久化 lastRunAt —— 重启 Electron 也知道上次什么时候跑的
|
||
const merged = { ...cfg, lastRunAt: now };
|
||
reportScheduleStore.saveSchedule(writeFn, merged);
|
||
console.log("[reportScheduler] ✅ 快照完成,lastRunAt 已持久化");
|
||
} catch (e) {
|
||
console.error("[reportScheduler] runSnapshot 失败:", e?.message || String(e));
|
||
} finally {
|
||
_inProgress = false;
|
||
}
|
||
};
|
||
|
||
// 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发)
|
||
const curCfg = reportScheduleStore.getSchedule(readFn);
|
||
console.log("[reportScheduler] 已启动,间隔", (curCfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
|
||
|
||
// 每 60 秒 tick 一次
|
||
_tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000);
|
||
_tickTimer.unref?.();
|
||
|
||
// 每秒检查是否到 dailyReportTime(HH:MM 精确匹配,每日只触发一次)
|
||
const dailyCheck = () => {
|
||
const cfg = reportScheduleStore.getSchedule(readFn);
|
||
if (!cfg.enabled) return;
|
||
const now = new Date();
|
||
const p = (n) => n.toString().padStart(2, "0");
|
||
const current = `${p(now.getHours())}:${p(now.getMinutes())}`;
|
||
const key = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
||
|
||
if (current === cfg.dailyReportTime && _lastDailyTriggerKey !== key) {
|
||
_lastDailyTriggerKey = key;
|
||
console.log("[reportScheduler] 触发日运行报告:", key);
|
||
triggerDailyReport(readFn, writeFn, { mode: "finalized", trigger: "scheduled" })
|
||
.then(report => {
|
||
if (onDailyReportReady) {
|
||
onDailyReportReady(report).catch(e =>
|
||
console.error("[reportScheduler] onDailyReportReady 异常:", e?.message || String(e))
|
||
);
|
||
}
|
||
})
|
||
.catch(e => console.error("[reportScheduler] 日报告生成异常:", e));
|
||
}
|
||
};
|
||
_dailyTimer = setInterval(dailyCheck, 30 * 1000);
|
||
_dailyTimer.unref?.();
|
||
}
|
||
|
||
function stop() {
|
||
if (_tickTimer) { clearInterval(_tickTimer); _tickTimer = null; }
|
||
if (_dailyTimer) { clearInterval(_dailyTimer); _dailyTimer = null; }
|
||
console.log("[reportScheduler] 已停止");
|
||
}
|
||
|
||
function isRunning() {
|
||
return !!_tickTimer;
|
||
}
|
||
|
||
/**
|
||
* 手动触发日运行报告(立即执行,不等到 HH:MM)
|
||
* mode: "provisional" — 取当天 0:00 ~ 此刻
|
||
* "finalized" — 取当天 0:00 ~ 23:59
|
||
*/
|
||
async function triggerDailyReport(readFn, writeFn, { mode = "provisional", trigger = "manual" } = {}) {
|
||
const now = Date.now();
|
||
const dayStart = dailyReportStore._dayStart(now);
|
||
const dayEnd = mode === "finalized" ? dailyReportStore._dayEnd(now) : now;
|
||
|
||
const allSnapshots = reportStore.listSnapshots(readFn, 1000);
|
||
const report = dailyReportStore.buildDailyReport(allSnapshots, dayStart, dayEnd, { mode, trigger });
|
||
dailyReportStore.addDailyReport(readFn, writeFn, report);
|
||
console.log(`[reportScheduler] 日报告 ${report.id} 已生成 (mode=${mode})`);
|
||
return report;
|
||
}
|
||
|
||
module.exports = {
|
||
start,
|
||
stop,
|
||
isRunning,
|
||
triggerDailyReport,
|
||
};
|