cron-up 26.10.25 → 26.10.27
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/package.json +1 -1
- package/src/args.mjs +36 -0
- package/src/cli.mjs +6 -39
- package/src/config.mjs +47 -34
- package/src/constants.mjs +8 -8
- package/src/cron.mjs +34 -34
- package/src/display.mjs +26 -26
- package/src/internals.mjs +37 -0
- package/src/logs.mjs +7 -14
- package/src/overview.mjs +21 -22
- package/src/paths.mjs +35 -35
- package/src/patrol.mjs +78 -61
- package/src/registry.mjs +9 -28
- package/src/service.mjs +39 -25
- package/src/sessions.mjs +16 -17
- package/src/tasks.mjs +41 -43
package/package.json
CHANGED
package/src/args.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 子命令选项规格与选项名转换。独立于 cli:install 的「未生效参数」名单
|
|
2
|
+
// (service.mjs)从同一张选项表派生,新增可持久化选项时两处自动同步。
|
|
3
|
+
|
|
4
|
+
// 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
|
|
5
|
+
// 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
|
|
6
|
+
export const SPECS = {
|
|
7
|
+
install: {
|
|
8
|
+
options: {
|
|
9
|
+
roots: 'string',
|
|
10
|
+
interval: 'int',
|
|
11
|
+
lead: 'int',
|
|
12
|
+
force: 'bool',
|
|
13
|
+
'auto-renew': 'tri',
|
|
14
|
+
'auto-min-id': 'tri',
|
|
15
|
+
'session-retain': 'string',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
uninstall: {
|
|
19
|
+
options: {
|
|
20
|
+
purge: 'bool',
|
|
21
|
+
'keep-sessions': 'bool',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
list: { options: {} },
|
|
25
|
+
run: { options: {} },
|
|
26
|
+
renew: { options: {} },
|
|
27
|
+
logs: {
|
|
28
|
+
options: { follow: 'bool', f: 'bool' },
|
|
29
|
+
positionalMax: 1,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
|
|
34
|
+
export function thisCamel(name) {
|
|
35
|
+
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
36
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -4,46 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
// 先引全量入口,保证所有业务模块都已向 deps 自注册,再引命令函数。
|
|
6
6
|
import './index.mjs';
|
|
7
|
-
import { deps, ExitError } from './internals.mjs';
|
|
7
|
+
import { deps, ExitError, isDecInt } from './internals.mjs';
|
|
8
8
|
import { cmdRun, cmdRenew } from './patrol.mjs';
|
|
9
9
|
import { cmdInstall, cmdUninstall } from './service.mjs';
|
|
10
10
|
import { cmdList } from './list.mjs';
|
|
11
11
|
import { cmdOverview } from './overview.mjs';
|
|
12
12
|
import { cmdLogs } from './logs.mjs';
|
|
13
|
+
import { SPECS, thisCamel } from './args.mjs';
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const SPECS = {
|
|
17
|
-
install: {
|
|
18
|
-
options: {
|
|
19
|
-
roots: 'string',
|
|
20
|
-
interval: 'int',
|
|
21
|
-
lead: 'int',
|
|
22
|
-
force: 'bool',
|
|
23
|
-
'auto-renew': 'tri',
|
|
24
|
-
'auto-min-id': 'tri',
|
|
25
|
-
'session-retain': 'string',
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
uninstall: {
|
|
29
|
-
options: {
|
|
30
|
-
purge: 'bool',
|
|
31
|
-
'keep-sessions': 'bool',
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
list: { options: {} },
|
|
35
|
-
run: { options: {} },
|
|
36
|
-
renew: { options: {} },
|
|
37
|
-
logs: {
|
|
38
|
-
options: { follow: 'bool', f: 'bool' },
|
|
39
|
-
positionalMax: 1,
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// 严格十进制整数命令行值:Number("0x10")、parseInt("5x") 都会静默给出错
|
|
44
|
-
// 误结果,必须显式拒绝。
|
|
15
|
+
// 严格十进制整数命令行值(谓词 isDecInt 见 internals):
|
|
16
|
+
// Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
|
|
45
17
|
function cliInt(s) {
|
|
46
|
-
if (
|
|
18
|
+
if (!isDecInt(s)) {
|
|
47
19
|
throw new ExitError(2, `需要整数却得到:${s}`);
|
|
48
20
|
}
|
|
49
21
|
return parseInt(s, 10);
|
|
@@ -102,11 +74,6 @@ function consumeOption(tok, tokens, i, spec, opts) {
|
|
|
102
74
|
return inlineValue !== undefined ? i + 1 : i + 2;
|
|
103
75
|
}
|
|
104
76
|
|
|
105
|
-
// 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
|
|
106
|
-
function thisCamel(name) {
|
|
107
|
-
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
108
|
-
}
|
|
109
|
-
|
|
110
77
|
export function parseCli(argv) {
|
|
111
78
|
let command = null;
|
|
112
79
|
const opts = {};
|
|
@@ -157,7 +124,7 @@ export function parseCli(argv) {
|
|
|
157
124
|
return result;
|
|
158
125
|
}
|
|
159
126
|
|
|
160
|
-
|
|
127
|
+
const USAGE = `cron-up —— 为定时任务提前备好交互会话
|
|
161
128
|
|
|
162
129
|
用法:
|
|
163
130
|
cron-up 查看总览(服务、配置、任务、会话)
|
package/src/config.mjs
CHANGED
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
deps,
|
|
9
|
+
ExitError,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
isDecInt,
|
|
12
|
+
ensurePrivateDir,
|
|
13
|
+
} from './internals.mjs';
|
|
14
|
+
import { DEFAULT_CONFIG, PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
|
|
15
|
+
import { expandHome, isDir } from './tasks.mjs';
|
|
10
16
|
|
|
11
17
|
// Shell 的 tilde 展开只发生在词首:"--roots ~/a,~/b" 穿过 shell 后第二个
|
|
12
18
|
// 条目前的 ~ 还在。读和写两边都规范化,配置里永远存绝对路径。
|
|
@@ -22,10 +28,6 @@ export function normalizeRoots(roots) {
|
|
|
22
28
|
return out;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
function isPlainObject(v) {
|
|
26
|
-
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
31
|
// 配置数值校验用:JSON 的 true 是布尔不能冒充秒数/深度
|
|
30
32
|
// (Number.isInteger(true) === false,天然挡住)。
|
|
31
33
|
export function isInt(x) {
|
|
@@ -35,18 +37,31 @@ export function isInt(x) {
|
|
|
35
37
|
// 宽容整型化(手改的 state/登记文件里字段可能是字符串或浮点):纯十进制
|
|
36
38
|
// 字符串接受,浮点截断,布尔拒绝;非法输入抛错由调用方按「字段不存在」
|
|
37
39
|
// 处理。
|
|
38
|
-
|
|
40
|
+
function lenientInt(v) {
|
|
39
41
|
if (typeof v === 'boolean') throw new Error('布尔不是整数字段');
|
|
40
42
|
if (typeof v === 'number') {
|
|
41
43
|
if (!Number.isFinite(v)) throw new Error('数值越界');
|
|
42
44
|
return Math.trunc(v); // 1.5 → 1
|
|
43
45
|
}
|
|
44
|
-
if (typeof v === 'string' &&
|
|
46
|
+
if (typeof v === 'string' && isDecInt(v.trim())) {
|
|
45
47
|
return parseInt(v.trim(), 10);
|
|
46
48
|
}
|
|
47
49
|
throw new Error(`无法整型化:${String(v)}`);
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
// pid 清洗:宽容解析后卡 pid_t 值域,不合法返回 null。state.json 与会话
|
|
53
|
+
// 登记两个外部来源共用同一条入口规则——超大整数能穿过类型转换直到
|
|
54
|
+
// process.kill 才抛,负数 pid 会被 kill 当成进程组号误伤无辜进程组。
|
|
55
|
+
export function parsePid(v) {
|
|
56
|
+
let pid;
|
|
57
|
+
try {
|
|
58
|
+
pid = lenientInt(v);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return pid > 0 && pid <= PID_T_MAX ? pid : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
// 配置字段的类型规则,loadConfig 与 install --force 继承旧配置共用。返回
|
|
51
66
|
// [字段名, 人话错误][]:手改把字段写坏时绝不静默回退——回退的两个可能方向
|
|
52
67
|
// (roots 坏 → 回扫整个家目录、autoRenew 坏 → 回开续期)都比停机更危险。
|
|
@@ -136,13 +151,7 @@ export function loadState() {
|
|
|
136
151
|
for (const [ws, raw] of Object.entries(state)) {
|
|
137
152
|
if (!isPlainObject(raw)) continue;
|
|
138
153
|
const ent = { ...raw };
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
pid = lenientInt(ent.pid);
|
|
142
|
-
} catch {
|
|
143
|
-
pid = null;
|
|
144
|
-
}
|
|
145
|
-
ent.pid = pid && pid > 0 && pid <= PID_T_MAX ? pid : null;
|
|
154
|
+
ent.pid = parsePid(ent.pid);
|
|
146
155
|
for (const k of ['startedAt', 'cooldownUntil', 'fails', 'deadSince']) {
|
|
147
156
|
if (ent[k] !== null && ent[k] !== undefined) {
|
|
148
157
|
try {
|
|
@@ -157,18 +166,11 @@ export function loadState() {
|
|
|
157
166
|
return out;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// 持现状。
|
|
169
|
+
// state 含工作区路径与 pid,目录按 ensurePrivateDir 收紧;文件以 0600 原
|
|
170
|
+
// 子替换——state 每轮都写、config 每次 install 都写,老版本留下的宽松权
|
|
171
|
+
// 限会自动跟进。
|
|
164
172
|
function atomicSave(file, doc) {
|
|
165
|
-
|
|
166
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
-
try {
|
|
168
|
-
fs.chmodSync(dir, 0o700);
|
|
169
|
-
} catch {
|
|
170
|
-
// 维持现状。
|
|
171
|
-
}
|
|
173
|
+
ensurePrivateDir(path.dirname(file));
|
|
172
174
|
const tmp = `${file}.tmp`;
|
|
173
175
|
// 以 0600 创建;残留的旧 tmp 更宽时也收回来,rename 保持该模式位。
|
|
174
176
|
fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, {
|
|
@@ -205,6 +207,23 @@ export function saveHeartbeat(doc) {
|
|
|
205
207
|
atomicSave(deps.paths.heartbeatPath, doc);
|
|
206
208
|
}
|
|
207
209
|
|
|
210
|
+
// 心跳是否已沉默超过 HEARTBEAT_STALE_FACTOR 个轮次间隔:轮首的漏轮提示与
|
|
211
|
+
// 总览页的「服务在但巡检没在跑」共用同一判据,两边不会各说各话。
|
|
212
|
+
export function heartbeatStale(hb, intervalSeconds, nowSec) {
|
|
213
|
+
return hb !== null && nowSec - hb.ranAt > intervalSeconds * HEARTBEAT_STALE_FACTOR;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 失败冷却的唯一判据:cooldownUntil 是秒级时间戳,0/缺省表示无冷却。巡检
|
|
217
|
+
// 决策、kept 过滤与总览展示共用,冷却表示法只渗到这两个函数。
|
|
218
|
+
export function isCooling(ent, cur) {
|
|
219
|
+
return (ent?.cooldownUntil ?? 0) > cur;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 距冷却结束还有多少整分钟(已结束为 0),只用于总览文案。
|
|
223
|
+
export function cooldownEtaMinutes(ent, cur) {
|
|
224
|
+
return Math.max(0, Math.floor(((ent?.cooldownUntil ?? 0) - cur) / 60));
|
|
225
|
+
}
|
|
226
|
+
|
|
208
227
|
export function saveConfig(cfg) {
|
|
209
228
|
atomicSave(deps.paths.configPath, cfg);
|
|
210
229
|
}
|
|
@@ -224,13 +243,7 @@ export function validateConfig(cfg, announce = () => {}) {
|
|
|
224
243
|
+ '两轮巡检之间到期而来不及提前启动会话,建议 lead ≥ interval');
|
|
225
244
|
}
|
|
226
245
|
for (const root of cfg.roots ?? []) {
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
dir = fs.statSync(root).isDirectory();
|
|
230
|
-
} catch {
|
|
231
|
-
dir = false;
|
|
232
|
-
}
|
|
233
|
-
if (!dir) warnings.push(`扫描目录不存在,巡检不会覆盖它:${root}`);
|
|
246
|
+
if (!isDir(root)) warnings.push(`扫描目录不存在,巡检不会覆盖它:${root}`);
|
|
234
247
|
}
|
|
235
248
|
for (const w of warnings) announce(`警告:${w}`);
|
|
236
249
|
return warnings;
|
package/src/constants.mjs
CHANGED
|
@@ -36,8 +36,9 @@ export const COOLDOWN_SECONDS = 1800;
|
|
|
36
36
|
// 稠密任务生效。保留 48 小时让稀疏任务跨窗口累积;老化防止陈年计数误伤
|
|
37
37
|
// 已修好的环境。
|
|
38
38
|
export const FAIL_TTL_SECONDS = 48 * 3600;
|
|
39
|
-
// pid 合法值域。state.json
|
|
40
|
-
//
|
|
39
|
+
// pid 合法值域。state.json 与会话登记两个外部来源的 pid 都在入口处
|
|
40
|
+
// (config 的 parsePid)按它统一校验:超大整数能穿过类型转换,直到
|
|
41
|
+
// process.kill 才抛,所以必须入口卡范围。
|
|
41
42
|
export const PID_T_MAX = 2 ** 31 - 1;
|
|
42
43
|
// 健康会话约 10 秒内完成登记。超时仍存活却没登记,是卡在无人应答的提问
|
|
43
44
|
// 界面(目录信任、工具授权);只看存活会把它当健康,只有这个时限兜得住。
|
|
@@ -48,9 +49,10 @@ export const WARMUP_GRACE_SECONDS = 180;
|
|
|
48
49
|
export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
|
|
49
50
|
export const SESSION_IDLE_SECONDS = 3600;
|
|
50
51
|
// window 模式回收判据一:界面静默超过此时长视为「本次执行已结束」。TUI
|
|
51
|
-
// 工作时持续渲染(毫秒级刷新 typescript),静默 3
|
|
52
|
-
// WARMUP_GRACE
|
|
53
|
-
|
|
52
|
+
// 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;取
|
|
53
|
+
// WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智(调一个跟着
|
|
54
|
+
// 动)。
|
|
55
|
+
export const SESSION_RECYCLE_IDLE_SECONDS = WARMUP_GRACE_SECONDS;
|
|
54
56
|
// window 模式回收判据二:距下次「需要会话在场」的时刻至少还有这么多秒。
|
|
55
57
|
// 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
|
|
56
58
|
// 发直接复用现有会话(时间紧邻的任务共享)。
|
|
@@ -98,7 +100,7 @@ export const CC_JITTER = {
|
|
|
98
100
|
// maxN,常量只保留「目标延迟」这一个可调项。
|
|
99
101
|
export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
|
|
100
102
|
|
|
101
|
-
// 投递尾窗判定的回看/前探窗口(wanted 与
|
|
103
|
+
// 投递尾窗判定的回看/前探窗口(wanted 与 nextDelivery 共用,见
|
|
102
104
|
// src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
|
|
103
105
|
// 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
|
|
104
106
|
// 点隔 4 年,世纪非闰年(2100)隔 8 年;窗口小了尾窗会塌缩成零:落点已
|
|
@@ -111,5 +113,3 @@ export const SEARCH_DAYS = 7;
|
|
|
111
113
|
// list/总览回答「下次何时触发」,要看到一年后:年度任务必须显示真实日期,
|
|
112
114
|
// 不能误报成「无安排」。nextAfter 的日历快进让长窗口依然便宜。
|
|
113
115
|
export const DISPLAY_SEARCH_DAYS = 366;
|
|
114
|
-
// list 问「一次性任务是否被错过」,与巡检同一个补执行判定,只是不带前瞻。
|
|
115
|
-
export const ZERO_LEAD_MS = 0;
|
package/src/cron.mjs
CHANGED
|
@@ -7,14 +7,15 @@ import {
|
|
|
7
7
|
CC_JITTER,
|
|
8
8
|
TAIL_WINDOW_DAYS,
|
|
9
9
|
} from './constants.mjs';
|
|
10
|
+
import { isDecInt } from './internals.mjs';
|
|
10
11
|
|
|
11
12
|
const MS_DAY = 86400_000;
|
|
12
13
|
const MS_MINUTE = 60_000;
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
15
|
+
// 严格十进制整数(isDecInt 的谓词见 internals):parseInt("5x") 会静默返回
|
|
16
|
+
// 5,必须自己卡。
|
|
16
17
|
function strictInt(s) {
|
|
17
|
-
if (
|
|
18
|
+
if (!isDecInt(s)) throw new Error(`不是整数:${s}`);
|
|
18
19
|
return parseInt(s, 10);
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -126,7 +127,7 @@ export class Cron {
|
|
|
126
127
|
// 不晚于 t 所在分钟的最近一次匹配(含当前分钟);withinDays 天前还没有
|
|
127
128
|
// 则 null。与 nextAfter 对称做整月/整小时快进,366 天窗口也便宜。用于
|
|
128
129
|
// 判断「现在是否落在某次触发的投递延迟尾窗里」。
|
|
129
|
-
prevAtOrBefore(t, withinDays
|
|
130
|
+
prevAtOrBefore(t, withinDays) {
|
|
130
131
|
if (!this.satisfiable) return null;
|
|
131
132
|
const cur = new Date(t.getTime());
|
|
132
133
|
cur.setSeconds(0, 0);
|
|
@@ -162,7 +163,7 @@ export class Cron {
|
|
|
162
163
|
|
|
163
164
|
// CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
|
|
164
165
|
// 标志时的兜底识别法。
|
|
165
|
-
|
|
166
|
+
function cronLooksOneshot(expr) {
|
|
166
167
|
const parts = expr.split(/\s+/);
|
|
167
168
|
return parts.length === 5 && parts[4] === '*'
|
|
168
169
|
&& parts.slice(0, 4).every((p) => /^\d+$/.test(p));
|
|
@@ -212,8 +213,7 @@ function wanted(cron, task, now, leadMs) {
|
|
|
212
213
|
new Date(created.getTime() - MS_MINUTE),
|
|
213
214
|
span,
|
|
214
215
|
);
|
|
215
|
-
|
|
216
|
-
return false;
|
|
216
|
+
return first !== null && first.getTime() <= now.getTime();
|
|
217
217
|
}
|
|
218
218
|
// 周期任务:投递不是在落点,而是落点 + 按 id 确定的抖动(每日任务通常
|
|
219
219
|
// 30 分)。落点已过、抖动投递时刻未到的这段「投递尾窗」里会话仍必须在
|
|
@@ -244,41 +244,41 @@ export function taskIdHash(taskId) {
|
|
|
244
244
|
return Number.isFinite(n) ? n : 0;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
247
|
+
// 任务在展示语境里的「落点 → 预计实际投递」对:一次性任务是落点加网格
|
|
248
|
+
// 提前量;周期任务在投递尾窗内属于上一落点(即将到来的这次延迟投递,
|
|
249
|
+
// 不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明天),
|
|
250
|
+
// 否则是下一落点 + 按 id 的抖动。先于窗口检查尾窗:2 月 29 日这类长周期
|
|
251
|
+
// 任务的下一落点可能在展示窗口之外,正在等待的投递却依然成立。taskView
|
|
252
|
+
// 的 nxt/fire 与 wanted 都以这里的落点为单一来源,两边不会各说各话。返回
|
|
253
|
+
// {slot, fire},均为 Date 或 null(窗口内没有落点时双双为 null)。cron 须
|
|
254
|
+
// 已解析且 satisfiable。
|
|
255
|
+
export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS) {
|
|
257
256
|
if (taskIsOneshot(task)) {
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
257
|
+
const slot = cron.nextAfter(now, withinDays);
|
|
258
|
+
if (slot === null) return { slot: null, fire: null };
|
|
260
259
|
// 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
|
|
261
260
|
// 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
|
|
262
|
-
if (
|
|
261
|
+
if (slot.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) {
|
|
262
|
+
return { slot, fire: slot };
|
|
263
|
+
}
|
|
264
|
+
const h = taskIdHash(task?.id);
|
|
263
265
|
const ahead = CC_JITTER.oneShotFloorMs
|
|
264
266
|
+ h * (CC_JITTER.oneShotMaxMs - CC_JITTER.oneShotFloorMs);
|
|
265
|
-
return new Date(Math.max(
|
|
267
|
+
return { slot, fire: new Date(Math.max(slot.getTime() - ahead, now.getTime())) };
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
// 尾窗内的周期任务:下一次真实投递是「上一落点 + 抖动」,数分钟内就
|
|
269
|
-
// 到,不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明
|
|
270
|
-
// 天(list 的落点列由 taskView 用 recurringTailWindow 对齐同一落点)。
|
|
271
|
-
// 先于窗口检查:2 月 29 日这类长周期任务的下一落点可能在展示窗口之
|
|
272
|
-
// 外,正在等待的投递却依然成立。
|
|
273
270
|
const tail = recurringTailWindow(cron, task?.id, now);
|
|
274
|
-
if (tail !== null) return new Date(tail[1]);
|
|
271
|
+
if (tail !== null) return { slot: tail[0], fire: new Date(tail[1]) };
|
|
275
272
|
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
273
|
+
const slot = cron.nextAfter(now, withinDays);
|
|
274
|
+
if (slot === null) return { slot: null, fire: null };
|
|
278
275
|
// 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
|
|
279
276
|
// 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
|
|
280
277
|
// 边缘取不到时与上游一致:视为无后续,不抖动。
|
|
281
|
-
return
|
|
278
|
+
return {
|
|
279
|
+
slot,
|
|
280
|
+
fire: new Date(slot.getTime() + recurringDelayMs(cron, slot, task?.id, withinDays)),
|
|
281
|
+
};
|
|
282
282
|
// 注:上游 bOt 另有一个 cacheLeadMs 分支——仅当相邻落点间隔落在
|
|
283
283
|
// [300000, 315000)ms(约 5 分钟周期的高频 cron,如 */5)且表达式匹配
|
|
284
284
|
// 其内部步进正则时,调度时刻提前 15 秒。日/周/月任务周期远大于此,不
|
|
@@ -287,7 +287,7 @@ export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
|
|
|
287
287
|
|
|
288
288
|
// 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
|
|
289
289
|
// slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
|
|
290
|
-
|
|
290
|
+
function recurringDelayMs(cron, slot, taskId, withinDays) {
|
|
291
291
|
const next = cron.nextAfter(new Date(slot.getTime()), withinDays);
|
|
292
292
|
if (next === null) return 0;
|
|
293
293
|
const period = next.getTime() - slot.getTime();
|
|
@@ -299,9 +299,9 @@ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
|
|
|
299
299
|
|
|
300
300
|
// 周期任务的「投递尾窗」判定:上一落点已过、按 id 算的延迟投递时刻未
|
|
301
301
|
// 到。返回 [落点, 投递时刻 ms];不在尾窗、找不到上一或下一落点返回
|
|
302
|
-
// null。wanted(保活判定)与
|
|
303
|
-
//
|
|
304
|
-
|
|
302
|
+
// null。wanted(保活判定)与 nextDelivery(展示)共用这一口径,两边不会
|
|
303
|
+
// 各说各话;窗口取 TAIL_WINDOW_DAYS,理由见 constants。
|
|
304
|
+
function recurringTailWindow(cron, taskId, now) {
|
|
305
305
|
const prev = cron.prevAtOrBefore(now, TAIL_WINDOW_DAYS);
|
|
306
306
|
if (prev === null) return null;
|
|
307
307
|
const fireMs = prev.getTime()
|
package/src/display.mjs
CHANGED
|
@@ -4,10 +4,8 @@ import {
|
|
|
4
4
|
parseCronOrNone,
|
|
5
5
|
taskIsOneshot,
|
|
6
6
|
wanted,
|
|
7
|
-
|
|
8
|
-
recurringTailWindow,
|
|
7
|
+
nextDelivery,
|
|
9
8
|
} from './cron.mjs';
|
|
10
|
-
import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
|
|
11
9
|
|
|
12
10
|
// 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
|
|
13
11
|
// 只覆盖任务摘要里真实出现的形态,区间外一律按 1 列——判错的代价仅是对
|
|
@@ -64,7 +62,7 @@ export function humanDelta(ms) {
|
|
|
64
62
|
|
|
65
63
|
// list/总览上代表任务身份的一句话:prompt 的首个非空行。真实任务第一行
|
|
66
64
|
// 通常自带标题;取不到时如实标注,不留空白格。
|
|
67
|
-
|
|
65
|
+
function taskSummary(task) {
|
|
68
66
|
const prompt = task?.prompt;
|
|
69
67
|
if (typeof prompt !== 'string' || !prompt.trim()) return '(无任务描述)';
|
|
70
68
|
const first = prompt.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
|
|
@@ -74,7 +72,7 @@ export function taskSummary(task) {
|
|
|
74
72
|
// 把一个任务整理成展示数据。字段来自别的程序写的文件,畸形也不抛。
|
|
75
73
|
// 传 leadMs 时顺带在同一个已解析 cron 上回答「现在是否需要预热会话」
|
|
76
74
|
// (wanted),总览页就不必为同一任务再解析一遍 cron。
|
|
77
|
-
export function taskView(task, now, leadMs
|
|
75
|
+
export function taskView(task, now, leadMs) {
|
|
78
76
|
const expr = task?.cron;
|
|
79
77
|
const cron = parseCronOrNone(expr);
|
|
80
78
|
if (cron === null || !cron.satisfiable) {
|
|
@@ -92,23 +90,21 @@ export function taskView(task, now, leadMs = undefined) {
|
|
|
92
90
|
}
|
|
93
91
|
const oneshot = taskIsOneshot(task);
|
|
94
92
|
// nxt 按定义不晚于展示语境里的下一次投递,「已错过」完全由补执行判定
|
|
95
|
-
//
|
|
96
|
-
const missed = oneshot && wanted(cron, task, now,
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const tail = oneshot ? null : recurringTailWindow(cron, task?.id, now);
|
|
102
|
-
const nxt = tail ? tail[0] : cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
|
|
93
|
+
// 回答:lead 传 0(不带前瞻),与巡检的补执行判定同一口径。
|
|
94
|
+
const missed = oneshot && wanted(cron, task, now, 0);
|
|
95
|
+
// 落点与投递由 nextDelivery 同源算出:尾窗内的周期任务即将到来的是上
|
|
96
|
+
// 一落点的这次延迟投递,箭头才画得出「09:00 → 09:30」,而不是从明天
|
|
97
|
+
// 的落点拐回来(2 月 29 日任务尾窗内 nextAfter 甚至找不到下一落点)。
|
|
98
|
+
const { slot, fire } = nextDelivery(cron, task, now);
|
|
103
99
|
return {
|
|
104
100
|
valid: true,
|
|
105
101
|
// 已错过的一次性任务没有未来触发:nxt 置 null,否则「一年内无」的判
|
|
106
102
|
// 断和总览「最近」(fire ?? nxt)会把它当成明年同刻的安排。
|
|
107
|
-
nxt: missed ? null :
|
|
103
|
+
nxt: missed ? null : slot,
|
|
108
104
|
missed,
|
|
109
105
|
kind: oneshot ? '一次性' : '周期',
|
|
110
106
|
permanent: Boolean(task.permanent) && !oneshot,
|
|
111
|
-
cadence:
|
|
107
|
+
cadence: String(expr),
|
|
112
108
|
summary: taskSummary(task),
|
|
113
109
|
// wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
|
|
114
110
|
// 都是 null,键的形状不随参数变化。
|
|
@@ -116,7 +112,7 @@ export function taskView(task, now, leadMs = undefined) {
|
|
|
116
112
|
// 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
|
|
117
113
|
// 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
|
|
118
114
|
// 与 nxt 同为 Date 或 null。
|
|
119
|
-
fire: missed ? null :
|
|
115
|
+
fire: missed ? null : fire,
|
|
120
116
|
};
|
|
121
117
|
}
|
|
122
118
|
|
|
@@ -126,11 +122,18 @@ function sameDay(a, b) {
|
|
|
126
122
|
&& a.getMonth() === b.getMonth()
|
|
127
123
|
&& a.getDate() === b.getDate();
|
|
128
124
|
}
|
|
129
|
-
|
|
125
|
+
// 「同一分钟」的唯一判据,fmtFireRange 的箭头省略与总览「设定」附注共用。
|
|
126
|
+
export function sameMinute(a, b) {
|
|
130
127
|
return sameDay(a, b) && a.getHours() === b.getHours()
|
|
131
128
|
&& a.getMinutes() === b.getMinutes();
|
|
132
129
|
}
|
|
133
130
|
|
|
131
|
+
// 两位数字补零:时间戳格式的公共零件,巡检日志的时间戳(patrol 的 log)
|
|
132
|
+
// 也复用。
|
|
133
|
+
export function pad2(n) {
|
|
134
|
+
return String(n).padStart(2, '0');
|
|
135
|
+
}
|
|
136
|
+
|
|
134
137
|
// 「设定落点 → 预计实际触发」文案。抖动四舍五入到秒(调度器秒级轮询):
|
|
135
138
|
// 同日省略第二个日期;预计值带非零秒时显示 HH:MM:SS(如 05:15:37);落
|
|
136
139
|
// 在同一分钟(无抖动)则只显示落点,不画无意义的箭头。
|
|
@@ -140,25 +143,22 @@ export function fmtFireRange(nxt, fire) {
|
|
|
140
143
|
}
|
|
141
144
|
const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
|
|
142
145
|
if (sameMinute(nxt, r)) return fmtMDHM(nxt);
|
|
143
|
-
const
|
|
146
|
+
const secs = r.getSeconds() ? `:${pad2(r.getSeconds())}` : '';
|
|
144
147
|
const tail = sameDay(nxt, r)
|
|
145
|
-
? `${
|
|
146
|
-
|
|
147
|
-
: `${fmtMDHM(r)}${r.getSeconds() ? `:${p(r.getSeconds())}` : ''}`;
|
|
148
|
+
? `${pad2(r.getHours())}:${pad2(r.getMinutes())}${secs}`
|
|
149
|
+
: `${fmtMDHM(r)}${secs}`;
|
|
148
150
|
return `${fmtMDHM(nxt)} → ${tail}`;
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
// 仅 HH:MM(总览「(设定 HH:MM)」附注用)。
|
|
152
154
|
export function fmtHM(d) {
|
|
153
|
-
|
|
154
|
-
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
155
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
// MM-DD HH:MM(任务触发点的统一展示格式)。
|
|
158
159
|
export function fmtMDHM(d) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
+ `${p(d.getMinutes())}`;
|
|
160
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
|
|
161
|
+
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// 会话年龄的粗粒度中文:长驻按天计,否则按分钟。
|
package/src/internals.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// 纯系统叶子的默认实现放这里;业务函数(discover、spawnSession…)由各自
|
|
7
7
|
// 模块注册。
|
|
8
8
|
|
|
9
|
+
import fs from 'node:fs';
|
|
9
10
|
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
10
12
|
import { paths } from './paths.mjs';
|
|
11
13
|
|
|
12
14
|
export class ExitError extends Error {
|
|
@@ -18,6 +20,18 @@ export class ExitError extends Error {
|
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// 外部程序写的 JSON 顶层形状守卫:config/state/任务文件三个入口共用,要收紧
|
|
24
|
+
// (如拒绝类实例)只改这一处。
|
|
25
|
+
export function isPlainObject(v) {
|
|
26
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 严格十进制整数(可选负号 + 纯数字):parseInt("5x")、Number("0x10") 都会
|
|
30
|
+
// 静默给出错误结果,来自外部字符串的整数一律先过这个谓词再 parseInt。
|
|
31
|
+
export function isDecInt(s) {
|
|
32
|
+
return /^-?\d+$/.test(s);
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
// 非 0 退出不抛:调用 ps/launchctl 的地方只关心 status 与输出,抛了反而
|
|
22
36
|
// 要在每个调用点 catch。返回 {status, stdout, stderr}。
|
|
23
37
|
function execFile(file, args, opts = {}) {
|
|
@@ -52,6 +66,29 @@ function terminalWidth() {
|
|
|
52
66
|
return Math.max(60, process.stdout.columns ?? 100);
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
// 自产物目录(state/config、会话日志)含工作区路径与会话记录,按私密收紧:
|
|
70
|
+
// 建好后 chmod 0700,顺带收回老版本留下的宽松权限;失败维持现状。三处写入
|
|
71
|
+
// 点共用这一条纪律。
|
|
72
|
+
export function ensurePrivateDir(dir) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
try {
|
|
75
|
+
fs.chmodSync(dir, 0o700);
|
|
76
|
+
} catch {
|
|
77
|
+
// 维持现状。
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
|
|
82
|
+
// 版本实验特性的依赖);读不到返回 null,总览页降级显示。
|
|
83
|
+
export function packageVersion() {
|
|
84
|
+
try {
|
|
85
|
+
const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
86
|
+
return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
export const deps = {
|
|
56
93
|
paths,
|
|
57
94
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
package/src/logs.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { deps, ExitError } from './internals.mjs';
|
|
9
|
-
import { onDiskCase } from './tasks.mjs';
|
|
9
|
+
import { onDiskCase, lexReal } from './tasks.mjs';
|
|
10
10
|
import { sessionLogPath } from './sessions.mjs';
|
|
11
|
+
import { fmtMDHM } from './display.mjs';
|
|
11
12
|
|
|
12
13
|
export function resolveLogPath(workspace, announce) {
|
|
13
14
|
const say = announce ?? ((m) => deps.printErr(m));
|
|
@@ -15,13 +16,7 @@ export function resolveLogPath(workspace, announce) {
|
|
|
15
16
|
if (path.isAbsolute(workspace)) {
|
|
16
17
|
// 日志名由工作区路径折成,而 spawn 记的是 discover 规范化后的真实
|
|
17
18
|
// 大小写:敲错大小写的绝对路径必须认回同一个文件。
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
real = fs.realpathSync(workspace);
|
|
21
|
-
} catch {
|
|
22
|
-
real = workspace;
|
|
23
|
-
}
|
|
24
|
-
const file = sessionLogPath(onDiskCase(real));
|
|
19
|
+
const file = sessionLogPath(onDiskCase(lexReal(workspace)));
|
|
25
20
|
return fs.existsSync(file) ? file : null;
|
|
26
21
|
}
|
|
27
22
|
// 只匹配当前日志(*.log):.log.1 若参与匹配,轮转过一次的工作区会永
|
|
@@ -47,12 +42,10 @@ export function resolveLogPath(workspace, announce) {
|
|
|
47
42
|
const err = path.join(deps.paths.logDir, 'launchd.err.log');
|
|
48
43
|
if (!fs.existsSync(out)) return err;
|
|
49
44
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
+ `${p(mtime.getHours())}:${p(mtime.getMinutes())}`;
|
|
55
|
-
say(`注:${err} 有内容(最后写入 ${stamp}),巡检若异常先看它`);
|
|
45
|
+
const st = fs.statSync(err);
|
|
46
|
+
if (st.size > 0) {
|
|
47
|
+
say(`注:${err} 有内容(最后写入 ${fmtMDHM(st.mtime)}),`
|
|
48
|
+
+ '巡检若异常先看它');
|
|
56
49
|
}
|
|
57
50
|
} catch {
|
|
58
51
|
// err 不存在:无事。
|