@wanghaopeng1148/deskpet 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -16
- package/bin/deskpet.mjs +70 -2
- package/dist/node/server/db/database.js +113 -0
- package/dist/node/server/db/migrate-legacy.js +88 -0
- package/dist/node/server/db/task-repository.js +374 -0
- package/dist/node/server/http/http-server.js +493 -0
- package/dist/node/server/http/ws-hub.js +59 -0
- package/dist/node/server/main.js +291 -0
- package/dist/node/server/plugins/actions/builtin.js +67 -0
- package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
- package/dist/node/server/plugins/actions/http-request.js +41 -0
- package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
- package/dist/node/server/plugins/actions/open-app.js +41 -0
- package/dist/node/server/plugins/actions/python-script.js +180 -0
- package/dist/node/server/plugins/actions/screenshot.js +38 -0
- package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
- package/dist/node/server/plugins/actions/show-reminder.js +7 -0
- package/dist/node/server/plugins/actions/ssh-command.js +123 -0
- package/dist/node/server/plugins/actions/task-chain.js +24 -0
- package/dist/node/server/plugins/actions/volume-control.js +31 -0
- package/dist/node/server/plugins/index.js +35 -0
- package/dist/node/server/plugins/registry.js +23 -0
- package/dist/node/server/services/clipboard-watcher.js +112 -0
- package/dist/node/server/services/config-store.js +141 -0
- package/dist/node/server/services/idle-monitor.js +131 -0
- package/dist/node/server/services/notifier.js +36 -0
- package/dist/node/server/services/quick-actions-store.js +52 -0
- package/dist/node/server/services/remote-connector.js +67 -0
- package/dist/node/server/services/scanner-reader.js +217 -0
- package/dist/node/server/services/script-runner.js +228 -0
- package/dist/node/server/services/snapshot-service.js +135 -0
- package/dist/node/server/services/task-scheduler.js +813 -0
- package/dist/node/server/services/wechat-bot.js +635 -0
- package/dist/node/server/services/wechat-command-types.js +1 -0
- package/dist/node/server/services/wechat-commands.js +330 -0
- package/dist/node/server/suppress-warnings.js +12 -0
- package/dist/node/server/utils/asset-url.js +26 -0
- package/dist/node/server/utils/auto-start.js +186 -0
- package/dist/node/server/utils/clipboard.js +50 -0
- package/dist/node/server/utils/dashboard-url.js +8 -0
- package/dist/node/server/utils/instance-guard.js +165 -0
- package/dist/node/server/utils/native-notify.js +53 -0
- package/dist/node/server/utils/open.js +37 -0
- package/dist/node/server/utils/paths.js +95 -0
- package/dist/node/server/utils/python-interpreter.js +129 -0
- package/dist/node/shared/animation-engine.js +349 -0
- package/dist/node/shared/chain-condition.js +39 -0
- package/dist/node/shared/cron-weekly.js +124 -0
- package/dist/node/shared/py-task-params.js +335 -0
- package/dist/node/shared/types.js +69 -0
- package/package.json +6 -2
- package/server/http/http-server.ts +4 -1
- package/server/utils/auto-start.ts +158 -49
- package/server/utils/paths.ts +28 -1
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务调度器 — 任务全生命周期管理(对齐旧版 task_scheduler.py 语义)
|
|
3
|
+
*
|
|
4
|
+
* 三类任务: manual / scheduled / scene
|
|
5
|
+
* 触发方式: once / cron / delay / interval / idle / startup / network
|
|
6
|
+
* 特性: 优先级队列、失败重试、一次性任务、执行历史 SQLite、任务链(M5 完善)
|
|
7
|
+
*/
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import schedule from 'node-schedule';
|
|
10
|
+
import { TaskStatus as TS } from "../../shared/types.js";
|
|
11
|
+
import { chainContextOf, evalChainCondition } from "../../shared/chain-condition.js";
|
|
12
|
+
import { newTaskId } from "../db/task-repository.js";
|
|
13
|
+
import { isProcessAlive } from "../utils/instance-guard.js";
|
|
14
|
+
/** 重试间隔(毫秒) */
|
|
15
|
+
const RETRY_DELAY_MS = 2000;
|
|
16
|
+
/** 实时输出缓冲上限(每个流,保留最后 64KB) */
|
|
17
|
+
const LIVE_OUTPUT_LIMIT = 64 * 1024;
|
|
18
|
+
/** 写入执行记录的输出上限(与正常收尾保持一致,避免超长输出撑爆库) */
|
|
19
|
+
const EXEC_OUTPUT_LIMIT = 10 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* 实时输出落盘节流间隔。
|
|
22
|
+
*
|
|
23
|
+
* 为什么不能让输出只留在内存:任务被中断时(服务退出 / 进程被强杀 / 直接关机)
|
|
24
|
+
* 走不到正常收尾,而 liveOutput 是内存字段,一旦进程消失输出就全没了 ——
|
|
25
|
+
* 表现就是执行记录里 stdout 空白,用户完全无从判断脚本跑到哪一步。
|
|
26
|
+
* 定期把已产生的输出写进 executions 表,即使进程被强杀也能保住中断前的部分。
|
|
27
|
+
*/
|
|
28
|
+
const OUTPUT_FLUSH_MS = 3000;
|
|
29
|
+
function appendTail(current, addition, limit) {
|
|
30
|
+
const next = current + addition;
|
|
31
|
+
return next.length <= limit ? next : next.slice(-limit);
|
|
32
|
+
}
|
|
33
|
+
function nowIso() {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
export class TaskScheduler extends EventEmitter {
|
|
37
|
+
records = new Map();
|
|
38
|
+
/** node-schedule cron 任务 */
|
|
39
|
+
jobs = new Map();
|
|
40
|
+
/** once/delay 定时器 */
|
|
41
|
+
timers = new Map();
|
|
42
|
+
/** interval 循环 */
|
|
43
|
+
intervals = new Map();
|
|
44
|
+
/** 待执行队列(按优先级降序) */
|
|
45
|
+
queue = [];
|
|
46
|
+
/** 当前执行中的任务 */
|
|
47
|
+
executingId = null;
|
|
48
|
+
/** 连续失败计数(熔断用) */
|
|
49
|
+
failStreak = new Map();
|
|
50
|
+
/** 运行中任务的执行记录行 id */
|
|
51
|
+
execRows = new Map();
|
|
52
|
+
/** 运行中任务的实时输出缓冲(供管理台流式查看,按 taskId 保存) */
|
|
53
|
+
liveOutput = new Map();
|
|
54
|
+
/** 输出有更新、等待落盘的任务(节流用) */
|
|
55
|
+
dirtyOutputs = new Set();
|
|
56
|
+
/** 输出落盘定时器 */
|
|
57
|
+
flushTimer = null;
|
|
58
|
+
startedFlag = false;
|
|
59
|
+
repo;
|
|
60
|
+
registry;
|
|
61
|
+
actionCtx;
|
|
62
|
+
constructor(repo, registry, actionCtx) {
|
|
63
|
+
super();
|
|
64
|
+
this.repo = repo;
|
|
65
|
+
this.registry = registry;
|
|
66
|
+
this.actionCtx = actionCtx;
|
|
67
|
+
}
|
|
68
|
+
// ── 生命周期 ──────────────────────────────────────────────
|
|
69
|
+
/** 从数据库载入全部任务 */
|
|
70
|
+
load() {
|
|
71
|
+
for (const dto of this.repo.listAll()) {
|
|
72
|
+
this.records.set(dto.id, dto);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 启动调度(注册定时触发 + 开机场景)。
|
|
77
|
+
*
|
|
78
|
+
* @param options.recover 是否执行「残留任务恢复」,默认 true。
|
|
79
|
+
* 调用方在**检测到已有另一个实例正在运行**时必须传 false:
|
|
80
|
+
* recoverInterrupted() 的判定是「库里凡是 running 的任务都是上个进程遗留的」,
|
|
81
|
+
* 但若任务其实正跑在另一个实例里,这一下就会把它误判成中断
|
|
82
|
+
* (见 utils/instance-guard.ts 的说明)。
|
|
83
|
+
*/
|
|
84
|
+
start(options = {}) {
|
|
85
|
+
if (this.startedFlag)
|
|
86
|
+
return;
|
|
87
|
+
this.startedFlag = true;
|
|
88
|
+
if (options.recover === false) {
|
|
89
|
+
console.warn('[scheduler] 检测到已有另一个实例在运行 → 跳过残留任务恢复,避免中断对方正在执行的任务');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// 收拾上次进程遗留的 running/pending,否则任务会被永久卡死
|
|
93
|
+
this.recoverInterrupted();
|
|
94
|
+
}
|
|
95
|
+
for (const rec of this.records.values()) {
|
|
96
|
+
this.applySchedule(rec);
|
|
97
|
+
}
|
|
98
|
+
// 开机场景任务
|
|
99
|
+
for (const rec of this.records.values()) {
|
|
100
|
+
if (rec.config.type === 'scene' &&
|
|
101
|
+
rec.config.trigger.type === 'startup' &&
|
|
102
|
+
rec.config.enabled) {
|
|
103
|
+
this.requestRun(rec.id, 'trigger');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.emit('started');
|
|
107
|
+
}
|
|
108
|
+
stop() {
|
|
109
|
+
this.startedFlag = false;
|
|
110
|
+
this.clearFlushTimer();
|
|
111
|
+
for (const job of this.jobs.values())
|
|
112
|
+
job.cancel();
|
|
113
|
+
this.jobs.clear();
|
|
114
|
+
for (const t of this.timers.values())
|
|
115
|
+
clearTimeout(t);
|
|
116
|
+
this.timers.clear();
|
|
117
|
+
for (const i of this.intervals.values())
|
|
118
|
+
clearInterval(i);
|
|
119
|
+
this.intervals.clear();
|
|
120
|
+
this.queue = [];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 进程退出前的收尾(优雅退出时由 main 调用,见 main.ts 的 shutdown)。
|
|
124
|
+
*
|
|
125
|
+
* 为什么必须单独做这件事:`stop()` 只清理定时器与队列,**不碰任务状态**。
|
|
126
|
+
* 若退出时正好有任务在执行,数据库里就会永远留下 status='running' 的任务
|
|
127
|
+
* 与执行记录;下次启动时 recoverInterrupted() 会把它判定为「上次被重启中断」
|
|
128
|
+
* 并把这条错误挂到任务上 —— 而用户只是正常按了 Ctrl+C,
|
|
129
|
+
* 看起来就像任务自己坏了。
|
|
130
|
+
*
|
|
131
|
+
* 与 recoverInterrupted() 的分工:
|
|
132
|
+
* · 正常退出 → 这里主动收尾(文案「服务退出」)
|
|
133
|
+
* · 进程被强杀 / 崩溃 → 来不及收尾,靠启动时 recoverInterrupted() 兜底(文案「服务重启」)
|
|
134
|
+
*/
|
|
135
|
+
shutdown() {
|
|
136
|
+
const executing = this.executingId;
|
|
137
|
+
const queued = [...this.queue];
|
|
138
|
+
// 排队中但尚未开始的任务:直接复位,不记为失败
|
|
139
|
+
for (const id of queued) {
|
|
140
|
+
const rec = this.records.get(id);
|
|
141
|
+
if (!rec)
|
|
142
|
+
continue;
|
|
143
|
+
rec.status = rec.config.enabled ? TS.IDLE : TS.DISABLED;
|
|
144
|
+
this.repo.update(id, rec.config, rec.status);
|
|
145
|
+
}
|
|
146
|
+
this.stop();
|
|
147
|
+
// 正在执行的任务:标记为「被服务退出中断」并落库
|
|
148
|
+
if (executing) {
|
|
149
|
+
const rec = this.records.get(executing);
|
|
150
|
+
if (rec) {
|
|
151
|
+
rec.status = rec.config.enabled ? TS.IDLE : TS.DISABLED;
|
|
152
|
+
rec.currentRetry = 0;
|
|
153
|
+
rec.lastResult = 'interrupted';
|
|
154
|
+
rec.lastError = '服务退出,执行被中断(非任务自身错误)';
|
|
155
|
+
this.repo.update(rec.id, rec.config, rec.status);
|
|
156
|
+
}
|
|
157
|
+
const execId = this.execRows.get(executing);
|
|
158
|
+
if (execId !== undefined) {
|
|
159
|
+
// 一并落盘已产生的输出:否则这条记录会是一条没有日志的空壳,
|
|
160
|
+
// 用户根本看不出脚本跑到哪一步(这正是「看不到标准输出」的由来)
|
|
161
|
+
const live = this.liveOutput.get(executing);
|
|
162
|
+
this.repo.updateExecution(execId, {
|
|
163
|
+
status: 'failed',
|
|
164
|
+
finishedAt: nowIso(),
|
|
165
|
+
error: '服务退出,执行被中断(非任务自身错误)',
|
|
166
|
+
stdout: (live?.stdout ?? '').slice(-EXEC_OUTPUT_LIMIT),
|
|
167
|
+
stderr: (live?.stderr ?? '').slice(-EXEC_OUTPUT_LIMIT)
|
|
168
|
+
});
|
|
169
|
+
this.execRows.delete(executing);
|
|
170
|
+
}
|
|
171
|
+
this.executingId = null;
|
|
172
|
+
}
|
|
173
|
+
this.liveOutput.clear();
|
|
174
|
+
this.runningScripts.clear();
|
|
175
|
+
}
|
|
176
|
+
get isStarted() {
|
|
177
|
+
return this.startedFlag;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 启动恢复:修正「上次进程被强杀 / 重启」留下的脏状态。
|
|
181
|
+
*
|
|
182
|
+
* 执行上下文(executingId / liveOutput / runningScripts)全在内存里,
|
|
183
|
+
* 进程一挂就没了,但数据库里任务状态和执行记录仍停在 running/pending。
|
|
184
|
+
* 不修正会有两个后果:
|
|
185
|
+
* 1. 界面自相矛盾——列表显示「运行中」,日志抽屉却显示「已结束」且没有任何输出;
|
|
186
|
+
* 2. 任务被彻底卡死——requestRun 里 `rec.status === TS.RUNNING` 会永久拦截入队,
|
|
187
|
+
* 用户再点「运行」只会得到「任务未入队」。
|
|
188
|
+
*
|
|
189
|
+
* @returns 被复位状态的任务数
|
|
190
|
+
*/
|
|
191
|
+
recoverInterrupted() {
|
|
192
|
+
let recovered = 0;
|
|
193
|
+
for (const rec of this.records.values()) {
|
|
194
|
+
if (rec.status !== TS.RUNNING && rec.status !== TS.PENDING)
|
|
195
|
+
continue;
|
|
196
|
+
// 回到可再次被触发的状态:启用中→空闲,已禁用→保持禁用
|
|
197
|
+
rec.status = rec.config.enabled ? TS.IDLE : TS.DISABLED;
|
|
198
|
+
rec.currentRetry = 0;
|
|
199
|
+
rec.lastResult = 'interrupted';
|
|
200
|
+
rec.lastError = '上次执行被服务重启中断';
|
|
201
|
+
this.repo.update(rec.id, rec.config, rec.status);
|
|
202
|
+
this.emit('status', this.statusEvent(rec));
|
|
203
|
+
recovered += 1;
|
|
204
|
+
}
|
|
205
|
+
// ── 收尾执行记录:先分辨「脚本真没了」还是「脚本仍在后台跑」 ──
|
|
206
|
+
//
|
|
207
|
+
// 旧实现是一刀切:把所有 status='running' 的记录收尾成「服务重启,执行被中断」。
|
|
208
|
+
// 但父进程被强杀时子进程不一定跟着死(Windows 尤其如此),于是会出现
|
|
209
|
+
// 「任务被判失败、python 其实还在改远端状态」——用户看到失败就会重点一次,
|
|
210
|
+
// 对部署类脚本来说这才是真正危险的地方。这里按 pid 存活情况分别定性。
|
|
211
|
+
const running = this.repo.listRunningExecutions();
|
|
212
|
+
const orphanIds = [];
|
|
213
|
+
const stillAlive = [];
|
|
214
|
+
for (const r of running) {
|
|
215
|
+
if (r.pid && isProcessAlive(r.pid))
|
|
216
|
+
stillAlive.push(r);
|
|
217
|
+
else
|
|
218
|
+
orphanIds.push(r.id);
|
|
219
|
+
}
|
|
220
|
+
const closed = this.repo.markRunningExecutionsInterrupted(undefined, undefined, orphanIds);
|
|
221
|
+
// 仍在后台运行的:如实标注(不擅自 kill,交给用户判断)
|
|
222
|
+
for (const r of stillAlive) {
|
|
223
|
+
const reason = `服务重启,执行被中断,但脚本仍在后台运行(pid ${r.pid})——` +
|
|
224
|
+
`重跑前请先确认它的状态,必要时手动结束该进程`;
|
|
225
|
+
this.repo.markRunningExecutionsInterrupted(reason, undefined, [r.id]);
|
|
226
|
+
const rec = this.records.get(r.taskId);
|
|
227
|
+
if (rec) {
|
|
228
|
+
rec.lastError = reason;
|
|
229
|
+
this.repo.update(rec.id, rec.config, rec.status);
|
|
230
|
+
this.emit('status', this.statusEvent(rec));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (stillAlive.length) {
|
|
234
|
+
console.warn(`[scheduler] 有 ${stillAlive.length} 个脚本在上次服务退出后仍在后台运行(pid: ${stillAlive
|
|
235
|
+
.map((r) => r.pid)
|
|
236
|
+
.join(', ')}),已如实标注,未擅自终止`);
|
|
237
|
+
}
|
|
238
|
+
// 内存态归零(进程刚起来时本就是这个值,显式重置以保证幂等)
|
|
239
|
+
this.queue = [];
|
|
240
|
+
this.executingId = null;
|
|
241
|
+
this.liveOutput.clear();
|
|
242
|
+
this.execRows.clear();
|
|
243
|
+
this.runningScripts.clear();
|
|
244
|
+
if (recovered || closed || stillAlive.length) {
|
|
245
|
+
this.emit('changed');
|
|
246
|
+
console.log(`[scheduler] 已恢复 ${recovered} 个中断任务,收尾 ${closed} 条未结束的执行记录` +
|
|
247
|
+
(stillAlive.length ? `,另有 ${stillAlive.length} 个脚本仍在后台运行` : ''));
|
|
248
|
+
}
|
|
249
|
+
return recovered;
|
|
250
|
+
}
|
|
251
|
+
// ── CRUD ──────────────────────────────────────────────────
|
|
252
|
+
listTasks() {
|
|
253
|
+
return [...this.records.values()].map((r) => ({ ...r }));
|
|
254
|
+
}
|
|
255
|
+
getTask(id) {
|
|
256
|
+
const r = this.records.get(id);
|
|
257
|
+
return r ? { ...r } : null;
|
|
258
|
+
}
|
|
259
|
+
createTask(config) {
|
|
260
|
+
const id = newTaskId();
|
|
261
|
+
const record = {
|
|
262
|
+
id,
|
|
263
|
+
config,
|
|
264
|
+
status: config.enabled ? TS.IDLE : TS.DISABLED,
|
|
265
|
+
createdAt: nowIso(),
|
|
266
|
+
executionCount: 0,
|
|
267
|
+
lastRunAt: null,
|
|
268
|
+
lastResult: null,
|
|
269
|
+
lastError: null,
|
|
270
|
+
nextRunAt: this.calcNextRun(config.trigger),
|
|
271
|
+
currentRetry: 0
|
|
272
|
+
};
|
|
273
|
+
this.records.set(id, record);
|
|
274
|
+
this.repo.insert(record);
|
|
275
|
+
this.applySchedule(record);
|
|
276
|
+
this.emit('changed');
|
|
277
|
+
return { ...record };
|
|
278
|
+
}
|
|
279
|
+
updateTask(id, config) {
|
|
280
|
+
const rec = this.records.get(id);
|
|
281
|
+
if (!rec)
|
|
282
|
+
return false;
|
|
283
|
+
rec.config = config;
|
|
284
|
+
rec.nextRunAt = this.calcNextRun(config.trigger);
|
|
285
|
+
if (!config.enabled)
|
|
286
|
+
rec.status = TS.DISABLED;
|
|
287
|
+
else if (rec.status === TS.DISABLED)
|
|
288
|
+
rec.status = TS.IDLE;
|
|
289
|
+
this.repo.update(id, config, rec.status);
|
|
290
|
+
this.unschedule(id);
|
|
291
|
+
this.applySchedule(rec);
|
|
292
|
+
this.emit('changed');
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
deleteTask(id) {
|
|
296
|
+
if (!this.records.has(id))
|
|
297
|
+
return false;
|
|
298
|
+
this.unschedule(id);
|
|
299
|
+
this.queue = this.queue.filter((q) => q !== id);
|
|
300
|
+
this.repo.delete(id);
|
|
301
|
+
this.records.delete(id);
|
|
302
|
+
this.emit('changed');
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
setEnabled(id, enabled) {
|
|
306
|
+
const rec = this.records.get(id);
|
|
307
|
+
if (!rec)
|
|
308
|
+
return false;
|
|
309
|
+
rec.config.enabled = enabled;
|
|
310
|
+
rec.status = enabled ? TS.IDLE : TS.DISABLED;
|
|
311
|
+
this.repo.update(id, rec.config, rec.status);
|
|
312
|
+
this.unschedule(id);
|
|
313
|
+
if (enabled)
|
|
314
|
+
this.applySchedule(rec);
|
|
315
|
+
this.emit('changed');
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
// ── 执行队列 ──────────────────────────────────────────────
|
|
319
|
+
/** 当前执行队列快照(运行中任务 + 按先后顺序排队中的任务) */
|
|
320
|
+
getQueueState() {
|
|
321
|
+
const nameOf = (id) => this.records.get(id)?.config.name ?? id;
|
|
322
|
+
return {
|
|
323
|
+
running: this.executingId ? { id: this.executingId, name: nameOf(this.executingId) } : null,
|
|
324
|
+
queue: this.queue.map((id) => ({ id, name: nameOf(id) }))
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** 队列变化广播(前端实时显示运行中 / 排队中) */
|
|
328
|
+
emitQueue() {
|
|
329
|
+
this.emit('queue', this.getQueueState());
|
|
330
|
+
}
|
|
331
|
+
// ── 实时输出(运行中即可查看 stdout/stderr) ──────────────
|
|
332
|
+
/** 追加实时输出并广播(由脚本输出回调驱动) */
|
|
333
|
+
appendLiveOutput(taskId, taskName, stream, text) {
|
|
334
|
+
if (!text)
|
|
335
|
+
return;
|
|
336
|
+
const buf = this.liveOutput.get(taskId) ?? { stdout: '', stderr: '' };
|
|
337
|
+
buf[stream] = appendTail(buf[stream], text, LIVE_OUTPUT_LIMIT);
|
|
338
|
+
this.liveOutput.set(taskId, buf);
|
|
339
|
+
this.emit('output', { taskId, taskName, stream, text, ts: Date.now() });
|
|
340
|
+
this.scheduleOutputFlush(taskId);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 标记该任务的输出待落盘,并按 OUTPUT_FLUSH_MS 节流统一写入。
|
|
344
|
+
* 避免每来一小段输出就写一次库(脚本输出可能非常密集)。
|
|
345
|
+
*/
|
|
346
|
+
scheduleOutputFlush(taskId) {
|
|
347
|
+
this.dirtyOutputs.add(taskId);
|
|
348
|
+
if (this.flushTimer)
|
|
349
|
+
return;
|
|
350
|
+
this.flushTimer = setInterval(() => this.flushLiveOutputs(), OUTPUT_FLUSH_MS);
|
|
351
|
+
this.flushTimer.unref?.();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* 把累积的实时输出写进对应的执行记录。
|
|
355
|
+
* 这样即使任务随后被中断(进程被杀 / 直接关机),用户仍能看到中断前已产生的日志。
|
|
356
|
+
*/
|
|
357
|
+
flushLiveOutputs() {
|
|
358
|
+
for (const taskId of this.dirtyOutputs) {
|
|
359
|
+
const execId = this.execRows.get(taskId);
|
|
360
|
+
const buf = this.liveOutput.get(taskId);
|
|
361
|
+
if (execId === undefined || !buf)
|
|
362
|
+
continue;
|
|
363
|
+
try {
|
|
364
|
+
this.repo.updateExecution(execId, {
|
|
365
|
+
stdout: buf.stdout.slice(-EXEC_OUTPUT_LIMIT),
|
|
366
|
+
stderr: buf.stderr.slice(-EXEC_OUTPUT_LIMIT)
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
/* 落盘失败不能影响任务本身,下次节拍或收尾时会再试 */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
this.dirtyOutputs.clear();
|
|
374
|
+
this.clearFlushTimer();
|
|
375
|
+
}
|
|
376
|
+
clearFlushTimer() {
|
|
377
|
+
if (this.flushTimer) {
|
|
378
|
+
clearInterval(this.flushTimer);
|
|
379
|
+
this.flushTimer = null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** 某个任务的实时输出快照(含是否正在运行) */
|
|
383
|
+
getLiveOutput(taskId) {
|
|
384
|
+
const buf = this.liveOutput.get(taskId) ?? { stdout: '', stderr: '' };
|
|
385
|
+
return { stdout: buf.stdout, stderr: buf.stderr, running: this.executingId === taskId };
|
|
386
|
+
}
|
|
387
|
+
/** 清空排队中的任务(不影响正在运行的那个),返回取消数量 */
|
|
388
|
+
clearQueue() {
|
|
389
|
+
const ids = [...this.queue];
|
|
390
|
+
this.queue = [];
|
|
391
|
+
for (const id of ids) {
|
|
392
|
+
const rec = this.records.get(id);
|
|
393
|
+
if (!rec)
|
|
394
|
+
continue;
|
|
395
|
+
rec.status = TS.CANCELLED;
|
|
396
|
+
this.repo.update(id, rec.config, TS.CANCELLED);
|
|
397
|
+
this.emit('status', this.statusEvent(rec));
|
|
398
|
+
}
|
|
399
|
+
this.emitQueue();
|
|
400
|
+
return ids.length;
|
|
401
|
+
}
|
|
402
|
+
/** 全部停止:终止运行中的脚本 + 清空排队 */
|
|
403
|
+
stopAll() {
|
|
404
|
+
const cleared = this.clearQueue();
|
|
405
|
+
let stopped = 0;
|
|
406
|
+
if (this.executingId) {
|
|
407
|
+
const handle = this.runningScripts.get(this.executingId);
|
|
408
|
+
if (handle) {
|
|
409
|
+
handle.kill();
|
|
410
|
+
stopped = 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { stopped, cleared };
|
|
414
|
+
}
|
|
415
|
+
// ── 执行 ──────────────────────────────────────────────────
|
|
416
|
+
/**
|
|
417
|
+
* 请求执行任务(手动或触发器)
|
|
418
|
+
* @returns 是否成功入队
|
|
419
|
+
*/
|
|
420
|
+
requestRun(id, source = 'manual') {
|
|
421
|
+
const rec = this.records.get(id);
|
|
422
|
+
if (!rec)
|
|
423
|
+
return false;
|
|
424
|
+
if (this.executingId === id || rec.status === TS.RUNNING)
|
|
425
|
+
return false;
|
|
426
|
+
if (this.queue.includes(id))
|
|
427
|
+
return false;
|
|
428
|
+
// 队列按请求先后入队(FIFO;优先级配置已移除)
|
|
429
|
+
this.queue.push(id);
|
|
430
|
+
if (rec.status !== TS.PENDING) {
|
|
431
|
+
rec.status = TS.PENDING;
|
|
432
|
+
}
|
|
433
|
+
void source;
|
|
434
|
+
this.emit('status', this.statusEvent(rec));
|
|
435
|
+
this.pump();
|
|
436
|
+
this.emitQueue();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
/** 停止任务:取消排队 / 终止运行中的脚本 */
|
|
440
|
+
stopTask(id) {
|
|
441
|
+
const rec = this.records.get(id);
|
|
442
|
+
if (!rec)
|
|
443
|
+
return false;
|
|
444
|
+
if (this.queue.includes(id)) {
|
|
445
|
+
this.queue = this.queue.filter((q) => q !== id);
|
|
446
|
+
rec.status = TS.CANCELLED;
|
|
447
|
+
this.repo.update(id, rec.config, rec.status);
|
|
448
|
+
this.emit('status', this.statusEvent(rec));
|
|
449
|
+
this.emitQueue();
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (this.executingId === id) {
|
|
453
|
+
const running = this.runningScripts.get(id);
|
|
454
|
+
running?.kill();
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
/** 运行中脚本的句柄(由 executeRecord 注册) */
|
|
460
|
+
runningScripts = new Map();
|
|
461
|
+
/** 带参执行的原始参数暂存(taskId → 原 scriptArgs) */
|
|
462
|
+
pendingArgOverrides = new Map();
|
|
463
|
+
/**
|
|
464
|
+
* 带参执行:临时覆盖 scriptArgs,执行完自动恢复原配置(微信「带参执行」指令)
|
|
465
|
+
*/
|
|
466
|
+
runTaskWithArgs(id, args) {
|
|
467
|
+
const rec = this.records.get(id);
|
|
468
|
+
if (!rec)
|
|
469
|
+
return false;
|
|
470
|
+
if (this.executingId === id || this.queue.includes(id))
|
|
471
|
+
return false;
|
|
472
|
+
this.pendingArgOverrides.set(id, rec.config.scriptArgs ?? []);
|
|
473
|
+
rec.config.scriptArgs = [...args];
|
|
474
|
+
const ok = this.requestRun(id, 'manual');
|
|
475
|
+
if (!ok)
|
|
476
|
+
this.pendingArgOverrides.delete(id);
|
|
477
|
+
return ok;
|
|
478
|
+
}
|
|
479
|
+
/** 注入运行中脚本句柄(python_script 动作启动时调用) */
|
|
480
|
+
trackRunningScript(taskId, handle) {
|
|
481
|
+
if (handle)
|
|
482
|
+
this.runningScripts.set(taskId, handle);
|
|
483
|
+
else
|
|
484
|
+
this.runningScripts.delete(taskId);
|
|
485
|
+
}
|
|
486
|
+
/** 场景触发:闲置时长变化 */
|
|
487
|
+
notifyIdle(idleMinutes) {
|
|
488
|
+
for (const rec of this.records.values()) {
|
|
489
|
+
const t = rec.config.trigger;
|
|
490
|
+
if (rec.config.type === 'scene' &&
|
|
491
|
+
t.type === 'idle' &&
|
|
492
|
+
t.idleMinutes &&
|
|
493
|
+
idleMinutes >= t.idleMinutes &&
|
|
494
|
+
rec.config.enabled &&
|
|
495
|
+
rec.status === TS.IDLE) {
|
|
496
|
+
this.requestRun(rec.id, 'trigger');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/** 场景触发:联网事件 */
|
|
501
|
+
notifyNetwork() {
|
|
502
|
+
this.runSceneByType('network');
|
|
503
|
+
}
|
|
504
|
+
/** 场景触发辅助 */
|
|
505
|
+
runSceneByType(type) {
|
|
506
|
+
for (const rec of this.records.values()) {
|
|
507
|
+
if (rec.config.type === 'scene' &&
|
|
508
|
+
rec.config.trigger.type === type &&
|
|
509
|
+
rec.config.enabled &&
|
|
510
|
+
rec.status === TS.IDLE) {
|
|
511
|
+
this.requestRun(rec.id, 'trigger');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// ── 内部:执行引擎 ────────────────────────────────────────
|
|
516
|
+
pump() {
|
|
517
|
+
if (this.executingId)
|
|
518
|
+
return;
|
|
519
|
+
const next = this.queue.shift();
|
|
520
|
+
if (!next)
|
|
521
|
+
return;
|
|
522
|
+
void this.executeRecord(next);
|
|
523
|
+
}
|
|
524
|
+
async executeRecord(id) {
|
|
525
|
+
const rec = this.records.get(id);
|
|
526
|
+
if (!rec) {
|
|
527
|
+
this.pump();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.executingId = id;
|
|
531
|
+
rec.status = TS.RUNNING;
|
|
532
|
+
rec.executionCount += 1;
|
|
533
|
+
rec.lastRunAt = nowIso();
|
|
534
|
+
this.repo.update(id, rec.config, TS.RUNNING, rec.executionCount);
|
|
535
|
+
this.emit('status', this.statusEvent(rec));
|
|
536
|
+
this.emit('busy', true);
|
|
537
|
+
this.emitQueue();
|
|
538
|
+
const startedAt = nowIso();
|
|
539
|
+
// 本次执行开始 → 重置实时输出缓冲(供管理台实时查看)
|
|
540
|
+
this.liveOutput.set(id, { stdout: '', stderr: '' });
|
|
541
|
+
const execId = this.repo.insertExecution({
|
|
542
|
+
taskId: id,
|
|
543
|
+
taskName: rec.config.name,
|
|
544
|
+
status: 'running',
|
|
545
|
+
startedAt,
|
|
546
|
+
finishedAt: null,
|
|
547
|
+
exitCode: null,
|
|
548
|
+
durationMs: null,
|
|
549
|
+
stdout: '',
|
|
550
|
+
stderr: '',
|
|
551
|
+
error: ''
|
|
552
|
+
});
|
|
553
|
+
this.execRows.set(id, execId);
|
|
554
|
+
const t0 = Date.now();
|
|
555
|
+
let result;
|
|
556
|
+
try {
|
|
557
|
+
// 每次执行注入独立的 onOutput 回调(把脚本实时输出推给管理台)
|
|
558
|
+
const runCtx = {
|
|
559
|
+
...this.actionCtx,
|
|
560
|
+
onOutput: (stream, text) => this.appendLiveOutput(id, rec.config.name, stream, text),
|
|
561
|
+
// 落库子进程 pid:服务中途被强杀时,靠它判断脚本是否还在后台跑
|
|
562
|
+
onChildPid: (pid) => {
|
|
563
|
+
try {
|
|
564
|
+
this.repo.updateExecution(execId, { pid });
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
/* 落库失败不能影响执行本身 */
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
result = await this.registry.execute(rec, runCtx);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
result = { success: false, error: String(err) };
|
|
575
|
+
}
|
|
576
|
+
const durationMs = Date.now() - t0;
|
|
577
|
+
// 执行已结束,停掉输出落盘定时器(下面会一次性写入完整结果)
|
|
578
|
+
this.dirtyOutputs.delete(id);
|
|
579
|
+
this.clearFlushTimer();
|
|
580
|
+
// 带参执行结束 → 恢复原配置参数
|
|
581
|
+
if (this.pendingArgOverrides.has(id)) {
|
|
582
|
+
rec.config.scriptArgs = this.pendingArgOverrides.get(id) ?? [];
|
|
583
|
+
this.pendingArgOverrides.delete(id);
|
|
584
|
+
this.repo.update(id, rec.config);
|
|
585
|
+
}
|
|
586
|
+
// 执行记录状态
|
|
587
|
+
const execStatus = result.timedOut
|
|
588
|
+
? 'timeout'
|
|
589
|
+
: result.success
|
|
590
|
+
? 'completed'
|
|
591
|
+
: 'failed';
|
|
592
|
+
this.repo.updateExecution(execId, {
|
|
593
|
+
status: execStatus,
|
|
594
|
+
finishedAt: nowIso(),
|
|
595
|
+
exitCode: result.exitCode ?? null,
|
|
596
|
+
durationMs,
|
|
597
|
+
stdout: (result.stdout ?? '').slice(-EXEC_OUTPUT_LIMIT),
|
|
598
|
+
stderr: (result.stderr ?? '').slice(-EXEC_OUTPUT_LIMIT),
|
|
599
|
+
error: result.error ?? ''
|
|
600
|
+
});
|
|
601
|
+
this.execRows.delete(id);
|
|
602
|
+
this.emit('execution', { taskId: id, taskName: rec.config.name, status: execStatus, durationMs, error: result.error ?? '' });
|
|
603
|
+
if (result.success) {
|
|
604
|
+
rec.lastResult = 'success';
|
|
605
|
+
rec.lastError = null;
|
|
606
|
+
this.setStatus(rec, TS.COMPLETED);
|
|
607
|
+
this.afterCompletion(rec, result);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
rec.lastResult = result.timedOut ? 'timeout' : 'failed';
|
|
611
|
+
rec.lastError = result.error ?? '未知错误';
|
|
612
|
+
this.handleFailure(rec);
|
|
613
|
+
}
|
|
614
|
+
this.executingId = null;
|
|
615
|
+
this.emit('busy', false);
|
|
616
|
+
this.pump();
|
|
617
|
+
this.emitQueue();
|
|
618
|
+
}
|
|
619
|
+
/** 成功后的通知与任务链 */
|
|
620
|
+
afterCompletion(rec, result) {
|
|
621
|
+
this.failStreak.delete(rec.id);
|
|
622
|
+
const channels = rec.config.notifyChannels ?? [];
|
|
623
|
+
if (channels.includes('desktop')) {
|
|
624
|
+
this.actionCtx.notifier.notify(`任务完成: ${rec.config.name}`, `耗时正常,状态成功`);
|
|
625
|
+
}
|
|
626
|
+
if (channels.includes('wechat')) {
|
|
627
|
+
this.actionCtx.notifier.sendWechat(`✅ 任务完成: ${rec.config.name}`);
|
|
628
|
+
}
|
|
629
|
+
// 任务链(方案 §3.3.B:支持 exit==0 / output contains 条件)
|
|
630
|
+
if (rec.config.chainNext && this.records.has(rec.config.chainNext)) {
|
|
631
|
+
const ok = evalChainCondition(rec.config.chainCondition, chainContextOf(result));
|
|
632
|
+
if (ok) {
|
|
633
|
+
this.requestRun(rec.config.chainNext, 'chain');
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** 失败处理(含重试,对齐旧版 _handle_failure) */
|
|
638
|
+
handleFailure(rec) {
|
|
639
|
+
const maxRetries = rec.config.retryCount ?? 0;
|
|
640
|
+
if (rec.currentRetry < maxRetries) {
|
|
641
|
+
rec.currentRetry += 1;
|
|
642
|
+
rec.status = TS.PENDING;
|
|
643
|
+
this.repo.update(rec.id, rec.config, TS.PENDING);
|
|
644
|
+
this.emit('status', this.statusEvent(rec));
|
|
645
|
+
const timer = setTimeout(() => {
|
|
646
|
+
this.requestRun(rec.id, 'retry');
|
|
647
|
+
}, RETRY_DELAY_MS);
|
|
648
|
+
timer.unref?.();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
rec.currentRetry = 0;
|
|
652
|
+
const note = maxRetries > 0 ? `重试 ${maxRetries} 次后仍失败` : '';
|
|
653
|
+
if (note) {
|
|
654
|
+
rec.lastError = rec.lastError ? `${rec.lastError}(${note})` : note;
|
|
655
|
+
}
|
|
656
|
+
// 失败熔断:连续失败达到阈值自动禁用并告警(方案外补充)
|
|
657
|
+
if (this.checkCircuitBreaker(rec))
|
|
658
|
+
return;
|
|
659
|
+
this.setStatus(rec, TS.FAILED);
|
|
660
|
+
}
|
|
661
|
+
/** 连续失败计数;达到阈值返回 true 并执行禁用+告警 */
|
|
662
|
+
checkCircuitBreaker(rec) {
|
|
663
|
+
let threshold = 3;
|
|
664
|
+
try {
|
|
665
|
+
const v = this.actionCtx.config.get?.('tasks.breakerThreshold');
|
|
666
|
+
if (typeof v === 'number' && v >= 0)
|
|
667
|
+
threshold = v;
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
/* 测试环境无配置存储时用默认值 */
|
|
671
|
+
}
|
|
672
|
+
if (threshold <= 0) {
|
|
673
|
+
this.failStreak.delete(rec.id);
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
const streak = (this.failStreak.get(rec.id) ?? 0) + 1;
|
|
677
|
+
this.failStreak.set(rec.id, streak);
|
|
678
|
+
if (streak < threshold)
|
|
679
|
+
return false;
|
|
680
|
+
this.failStreak.delete(rec.id);
|
|
681
|
+
rec.config.enabled = false;
|
|
682
|
+
rec.lastError = `${rec.lastError ?? '任务失败'}\n⛔ 已连续失败 ${streak} 次,任务被自动禁用(失败熔断)`;
|
|
683
|
+
this.repo.update(rec.id, rec.config, TS.FAILED);
|
|
684
|
+
this.setStatus(rec, TS.DISABLED);
|
|
685
|
+
this.actionCtx.notifier.notify(`任务已熔断: ${rec.config.name}`, `连续失败 ${streak} 次,已自动禁用。请到管理台排查后重新启用。`);
|
|
686
|
+
this.actionCtx.notifier.sendWechat(`⛔ 任务熔断: ${rec.config.name}\n连续失败 ${streak} 次,已自动禁用`);
|
|
687
|
+
this.emit('changed');
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
setStatus(rec, status) {
|
|
691
|
+
rec.status = status;
|
|
692
|
+
this.repo.update(rec.id, rec.config, status);
|
|
693
|
+
this.emit('status', this.statusEvent(rec));
|
|
694
|
+
// 一次性任务:终态后自动删除
|
|
695
|
+
if (rec.config.oneTime &&
|
|
696
|
+
(status === TS.COMPLETED || status === TS.FAILED)) {
|
|
697
|
+
this.deleteTask(rec.id);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
statusEvent(rec) {
|
|
701
|
+
return {
|
|
702
|
+
taskId: rec.id,
|
|
703
|
+
taskName: rec.config.name,
|
|
704
|
+
status: rec.status,
|
|
705
|
+
result: rec.lastResult ?? undefined
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
// ── 内部:触发注册 ────────────────────────────────────────
|
|
709
|
+
applySchedule(rec) {
|
|
710
|
+
if (rec.config.type !== 'scheduled' || !rec.config.enabled)
|
|
711
|
+
return;
|
|
712
|
+
const t = rec.config.trigger;
|
|
713
|
+
switch (t.type) {
|
|
714
|
+
case 'cron':
|
|
715
|
+
if (t.cron) {
|
|
716
|
+
try {
|
|
717
|
+
const job = schedule.scheduleJob(t.cron, () => this.triggered(rec.id));
|
|
718
|
+
if (job)
|
|
719
|
+
this.jobs.set(rec.id, job);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
console.error(`[scheduler] cron 注册失败 (${rec.config.name}):`, err);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
break;
|
|
726
|
+
case 'once':
|
|
727
|
+
if (t.datetime) {
|
|
728
|
+
const delay = this.onceDelayMs(t.datetime);
|
|
729
|
+
if (delay > 0) {
|
|
730
|
+
const timer = setTimeout(() => this.triggered(rec.id), delay);
|
|
731
|
+
timer.unref?.();
|
|
732
|
+
this.timers.set(rec.id, timer);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
break;
|
|
736
|
+
case 'delay':
|
|
737
|
+
if (t.delaySeconds && t.delaySeconds > 0) {
|
|
738
|
+
const timer = setTimeout(() => this.triggered(rec.id), t.delaySeconds * 1000);
|
|
739
|
+
timer.unref?.();
|
|
740
|
+
this.timers.set(rec.id, timer);
|
|
741
|
+
}
|
|
742
|
+
break;
|
|
743
|
+
case 'interval':
|
|
744
|
+
if (t.intervalMinutes && t.intervalMinutes > 0) {
|
|
745
|
+
const iv = setInterval(() => this.triggered(rec.id), t.intervalMinutes * 60 * 1000);
|
|
746
|
+
iv.unref?.();
|
|
747
|
+
this.intervals.set(rec.id, iv);
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
default:
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
unschedule(id) {
|
|
755
|
+
this.jobs.get(id)?.cancel();
|
|
756
|
+
this.jobs.delete(id);
|
|
757
|
+
const t = this.timers.get(id);
|
|
758
|
+
if (t)
|
|
759
|
+
clearTimeout(t);
|
|
760
|
+
this.timers.delete(id);
|
|
761
|
+
const iv = this.intervals.get(id);
|
|
762
|
+
if (iv)
|
|
763
|
+
clearInterval(iv);
|
|
764
|
+
this.intervals.delete(id);
|
|
765
|
+
}
|
|
766
|
+
/** 定时触发回调(对齐旧版 _scheduled_trigger) */
|
|
767
|
+
triggered(id) {
|
|
768
|
+
const rec = this.records.get(id);
|
|
769
|
+
if (!rec || !rec.config.enabled)
|
|
770
|
+
return;
|
|
771
|
+
const t = rec.config.trigger;
|
|
772
|
+
if (t.maxExecutions > 0 && rec.executionCount >= t.maxExecutions) {
|
|
773
|
+
this.unschedule(id);
|
|
774
|
+
this.setStatus(rec, TS.COMPLETED);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
// 工作日近似检测(周末跳过;法定节假日表暂不实现)
|
|
778
|
+
if (t.holidayCheck) {
|
|
779
|
+
const day = new Date().getDay();
|
|
780
|
+
if (day === 0 || day === 6)
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.requestRun(id, 'trigger');
|
|
784
|
+
}
|
|
785
|
+
/** once 时间解析:"YYYY-MM-DD HH:MM" 本地时间 */
|
|
786
|
+
onceDelayMs(datetime) {
|
|
787
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(datetime.trim());
|
|
788
|
+
if (!m)
|
|
789
|
+
return -1;
|
|
790
|
+
const [, y, mo, d, h, mi] = m;
|
|
791
|
+
const target = new Date(+y, +mo - 1, +d, +h, +mi, 0, 0);
|
|
792
|
+
return target.getTime() - Date.now();
|
|
793
|
+
}
|
|
794
|
+
calcNextRun(trigger) {
|
|
795
|
+
if (trigger.type === 'once' && trigger.datetime) {
|
|
796
|
+
const delay = this.onceDelayMs(trigger.datetime);
|
|
797
|
+
if (delay > 0)
|
|
798
|
+
return new Date(Date.now() + delay).toISOString();
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
if (trigger.type === 'delay' && trigger.delaySeconds) {
|
|
802
|
+
return new Date(Date.now() + trigger.delaySeconds * 1000).toISOString();
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
/** 供测试/调试:清空内存态 */
|
|
807
|
+
resetInMemory() {
|
|
808
|
+
this.records.clear();
|
|
809
|
+
this.queue = [];
|
|
810
|
+
this.stop();
|
|
811
|
+
this.startedFlag = false;
|
|
812
|
+
}
|
|
813
|
+
}
|