cron-up 26.10.30 → 26.10.32

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/src/overview.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // 无子命令时的总览页:一眼看清服务、配置、任务、保活会话与需要留意的
1
+ // 无子命令时的总览页:一眼看清服务、配置、任务、预热会话与需要留意的
2
2
  // 事。它回答「现在一切正常吗」:除汇总现状外,还把巡检视角的异常集中成
3
3
  // 一个「需要留意」清单。
4
4
 
@@ -6,6 +6,10 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { deps, packageVersion } from './internals.mjs';
9
+ import {
10
+ INTERVAL_SECONDS,
11
+ LEAD_SECONDS,
12
+ } from './constants.mjs';
9
13
  import {
10
14
  loadConfig,
11
15
  loadState,
@@ -15,7 +19,7 @@ import {
15
19
  isCooling,
16
20
  cooldownEtaMinutes,
17
21
  } from './config.mjs';
18
- import { taskFileExists } from './tasks.mjs';
22
+ import { taskFileExists, taskKey } from './tasks.mjs';
19
23
  import { serviceLine, launcherHealth } from './service.mjs';
20
24
  import {
21
25
  humanDelta,
@@ -30,21 +34,28 @@ import {
30
34
  sameMinute,
31
35
  } from './display.mjs';
32
36
 
33
- // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
34
- // 的工作区集合](第三个供会话部分识别「已移出巡检视野」的会话)。
35
- function overviewTasks(cfg, cols, consumers, state) {
37
+ // entry 此刻是否算「正在承接任务」:script 活(启动中或已认亲),或用户
38
+ // --resume 接管后 sid 仍活。冷却/死条目不报「没有会话」。
39
+ function entryHolds(ent, details, ws) {
40
+ if (!ent) return false;
41
+ if (deps.trackedAlive(ent)) return true;
42
+ return typeof ent.sessionId === 'string'
43
+ && (details.get(ws) ?? []).some((d) => d.sessionId === ent.sessionId);
44
+ }
45
+
46
+ // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的 entry 键集合, 本轮发
47
+ // 现的工作区集合](第三个供会话部分识别「已移出巡检视野」的会话)。
48
+ function overviewTasks(cfg, cols, details, state) {
36
49
  const alerts = [];
37
50
  const coolingWarned = new Set();
38
51
  const discovered = new Set();
39
52
  const now = new Date();
40
- const cur = Math.floor(now.getTime() / 1000);
41
- const leadMs = cfg.leadSeconds * 1000;
53
+ const leadMs = LEAD_SECONDS * 1000;
42
54
 
43
55
  let nWs = 0;
44
56
  let nTasks = 0;
45
- let unrenewed = 0;
46
57
  let soonest = null;
47
- for (const [ws] of deps.discover(cfg.roots, cfg.maxDepth)) {
58
+ for (const [ws] of deps.discover(cfg.roots)) {
48
59
  discovered.add(ws);
49
60
  const tasks = deps.readTasks(ws);
50
61
  if (tasks === null) {
@@ -58,13 +69,9 @@ function overviewTasks(cfg, cols, consumers, state) {
58
69
  if (tasks.length === 0) continue;
59
70
  nWs += 1;
60
71
  nTasks += tasks.length;
72
+ const liveSids = deps.liveSessionIds(details, ws);
61
73
  let bad = 0;
62
- let wanted = false;
63
74
  for (const t of tasks) {
64
- // 与 renew 同一条字段规则:显式周期任务却没有 truthy permanent,仍
65
- // 受 7 天过期约束。从原始任务计数,与 cron 合法性无关,坏 cron 也照
66
- // 提醒。
67
- if (t.recurring && !t.permanent) unrenewed += 1;
68
75
  const v = taskView(t, now, leadMs);
69
76
  if (!v.valid) {
70
77
  bad += 1;
@@ -75,33 +82,34 @@ function overviewTasks(cfg, cols, consumers, state) {
75
82
  if (fire && (!soonest || fire.getTime() < soonest[0].getTime())) {
76
83
  soonest = [fire, ws, v.summary, v.nxt];
77
84
  }
78
- if (v.wanted) wanted = true;
79
- }
80
- if (bad > 0) {
81
- alerts.push(`${ws} 有 ${bad} 个任务的 cron 无法解析,`
82
- + `巡检会跳过${bad > 1 ? '它们' : '它'}`);
83
- }
84
- if (wanted && !consumers.has(ws)) {
85
- const ent = state[ws] ?? {};
86
- if (isCooling(ent, cur)) {
87
- // 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
88
- // 矛盾,这里合并成一条。
85
+ if (!v.wanted) continue;
86
+ // 窗口内任务:创建者会话还开着(任务铁定投进它)或我们的预热已在
87
+ // 承接,都不是异常;否则按 entry 的冷却/失败状态给一条可执行的告
88
+ // 警。
89
+ const key = taskKey(t);
90
+ const ent = state[ws]?.[key];
91
+ if (deps.creatorSessionAlive(t, liveSids) || entryHolds(ent, details, ws)) {
92
+ continue;
93
+ }
94
+ if (isCooling(ent, Math.floor(now.getTime() / 1000))) {
95
+ const cur = Math.floor(now.getTime() / 1000);
89
96
  const eta = cooldownEtaMinutes(ent, cur);
90
97
  alerts.push(
91
- `${ws} 有任务即将执行(或错过待补执行),但保活会话连续失败 `
98
+ `${ws} 任务 ${key} 即将执行(或错过待补执行),但预热连续失败 `
92
99
  + `${ent.fails ?? '?'} 次正处冷却中,约 ${eta} 分钟后才会重试;`
93
- + `请运行 cron-up logs ${path.basename(ws)} 排查`);
94
- coolingWarned.add(ws);
100
+ + `请运行 cron-up logs ${path.basename(ws)}--${key} 排查`);
101
+ coolingWarned.add(`${ws}\0${key}`);
95
102
  } else {
96
- // 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
97
- // 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
98
- // 告警解释,这里不重复。
99
- const fails = ent.fails ?? 0;
103
+ const fails = ent?.fails ?? 0;
100
104
  const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
101
- alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
102
- + '但当前没有交互会话,下轮巡检会自动启动');
105
+ alerts.push(`${ws} 任务 ${key} 即将执行(或错过待补执行)${streak},`
106
+ + '但当前没有承接会话,下轮巡检会自动启动预热');
103
107
  }
104
108
  }
109
+ if (bad > 0) {
110
+ alerts.push(`${ws} 有 ${bad} 个任务的 cron 无法解析,`
111
+ + `巡检会跳过${bad > 1 ? '它们' : '它'}`);
112
+ }
105
113
  }
106
114
 
107
115
  deps.print(nTasks > 0
@@ -116,52 +124,64 @@ function overviewTasks(cfg, cols, consumers, state) {
116
124
  + `${setTag}) ${path.basename(ws)} · ${summary}`;
117
125
  deps.print(clip(line, cols));
118
126
  }
119
- if (!cfg.autoRenew && unrenewed > 0) {
120
- alerts.push(
121
- `自动续期已关闭:${unrenewed} 个周期任务尚未标记 permanent,创建满 7 `
122
- + '天后会末次执行并被删除。运行 cron-up renew 立即续期,或在配置中'
123
- + '开启 autoRenew');
124
- }
125
127
  return [alerts, coolingWarned, discovered];
126
128
  }
127
129
 
128
- function overviewSessions(cols, state, suppressCooling, discovered) {
130
+ function overviewSessions(cols, state, details, suppressCooling, discovered) {
129
131
  const alerts = [];
130
132
  const cur = Math.floor(Date.now() / 1000);
131
133
  const live = [];
134
+ const detached = [];
132
135
  const cooling = [];
133
136
  const dead = [];
134
- for (const [ws, ent] of Object.entries(state)) {
135
- if (isCooling(ent, cur)) cooling.push([ws, ent]);
136
- else if (deps.trackedAlive(ent)) live.push([ws, ent]);
137
- else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
137
+ for (const [ws, bucket] of Object.entries(state)) {
138
+ for (const [taskId, ent] of Object.entries(bucket)) {
139
+ const sidAlive = typeof ent.sessionId === 'string'
140
+ && (details.get(ws) ?? []).some((d) => d.sessionId === ent.sessionId);
141
+ if (isCooling(ent, cur)) {
142
+ cooling.push([ws, taskId, ent]);
143
+ } else if (deps.trackedAlive(ent)) {
144
+ live.push([ws, taskId, ent]);
145
+ } else if (sidAlive) {
146
+ detached.push([ws, taskId, ent]);
147
+ } else {
148
+ dead.push([ws, taskId]);
149
+ }
150
+ }
138
151
  }
139
152
  let tail = '';
140
153
  if (cooling.length) tail += `,冷却 ${cooling.length} 个`;
154
+ if (detached.length) tail += `,终端接管 ${detached.length} 个`;
141
155
  if (dead.length) tail += `,已失效 ${dead.length} 个`;
156
+ const describe = ([ws, taskId, ent]) =>
157
+ `${path.basename(ws)}/${taskId}(pid ${ent.pid},已运行 `
158
+ + `${sessionAgeZh(ent.startedAt ?? cur, cur)})`;
142
159
  if (live.length) {
143
- const desc = live.map(([ws, ent]) =>
144
- `${path.basename(ws)}(pid ${ent.pid},已运行 `
145
- + `${sessionAgeZh(ent.startedAt ?? cur, cur)})`).join('、');
146
- const head = `会话:保活 ${live.length} 个(`;
147
- const fixed = dispWidth(head) + dispWidth(tail) + dispWidth(')');
148
- deps.print(`${head}${clip(desc, cols - fixed)})${tail}`);
160
+ const head = '预热会话:';
161
+ const avail = cols - dispWidth(head) - dispWidth(tail)
162
+ - (tail ? dispWidth('()') : 0);
163
+ const desc = clip(live.map(describe).join('、'), Math.max(0, avail));
164
+ deps.print(`${head}${live.length} 个:${desc}${tail ? `(${tail.slice(1)})` : ''}`);
149
165
  } else {
150
- deps.print(`会话:无保活会话${tail}`);
166
+ deps.print(`预热会话:无${tail ? `(${tail.slice(1)})` : ''}`);
167
+ }
168
+ for (const [ws, taskId] of detached) {
169
+ deps.print(` · ${path.basename(ws)}/${taskId}:已在终端 --resume 接管,`
170
+ + '巡检不再另拉;该会话关闭后任务恢复自动预热');
151
171
  }
152
- for (const [ws, ent] of cooling) {
153
- if (suppressCooling.has(ws)) continue; // 任务区已给过带上下文的合并告警
172
+ for (const [ws, taskId, ent] of cooling) {
173
+ if (suppressCooling.has(`${ws}\0${taskId}`)) continue;
154
174
  const eta = cooldownEtaMinutes(ent, cur);
155
- alerts.push(`${ws} 保活连续失败 ${ent.fails ?? '?'} 次,冷却中,约 ${eta} `
156
- + '分钟后重试');
175
+ alerts.push(`${ws} 任务 ${taskId} 预热连续失败 ${ent.fails ?? '?'} 次,`
176
+ + `冷却中,约 ${eta} 分钟后重试`);
157
177
  }
158
178
  if (discovered !== null) {
159
- for (const [ws, ent] of live) {
179
+ for (const [ws, taskId, ent] of live) {
160
180
  if (!discovered.has(ws)) {
161
181
  alerts.push(
162
- `${ws} 的保活会话(pid ${ent.pid})仍在运行,但该目录已不在巡检范`
163
- + '围内(任务文件被删或已移出 roots),cron-up 不会再回收或换代'
164
- + '它;不需要时请自行结束');
182
+ `${ws} 任务 ${taskId} 的预热会话(pid ${ent.pid})仍在运行,但该`
183
+ + '目录已不在巡检范围内(任务文件被删或已移出 roots),cron-up 不'
184
+ + '会再回收它;不需要时请自行结束');
165
185
  }
166
186
  }
167
187
  }
@@ -169,11 +189,10 @@ function overviewSessions(cols, state, suppressCooling, discovered) {
169
189
  }
170
190
 
171
191
  const COMMANDS = [
172
- ['list', '逐个列出任务的内容摘要、设定/预计触发时间与会话状态'],
192
+ ['list', '逐个列出任务的内容摘要、设定/预计触发时间与预热状态'],
173
193
  ['logs [目录片段] [-f]', '查看巡检日志或后台会话记录'],
174
194
  ['run', '立即手动巡检一轮'],
175
- ['renew', '立即给所有周期任务补 permanent 并改小 id'],
176
- ['install --force', '更新配置并重新安装(全部参数见 --help)'],
195
+ ['install --roots ~/a,~/b', '更新扫描目录并重新安装(不带参数保留现有配置)'],
177
196
  ];
178
197
 
179
198
  export async function cmdOverview(args) {
@@ -181,7 +200,7 @@ export async function cmdOverview(args) {
181
200
  const v = packageVersion();
182
201
  deps.print(v === null
183
202
  ? 'cron-up —— 为定时任务提前备好交互会话'
184
- : `cron-up ${v} —— 为定时任务提前备好交互会话`);
203
+ : `cron-up ${v} —— 为定时任务提前备好交互会话(一任务一会话)`);
185
204
 
186
205
  const cfgPath = args?.config ?? deps.paths.configPath;
187
206
  let cfg = null;
@@ -196,76 +215,56 @@ export async function cmdOverview(args) {
196
215
  deps.print(serviceLine(info, fs.existsSync(deps.paths.plistPath)));
197
216
 
198
217
  const alerts = [];
199
- // plist 写死的 node/入口路径失效(Node 版本目录被清)时 launchd 每轮
200
- // 都静默拉不起来,这是总览唯一能发现它的地方。
201
218
  const health = launcherHealth();
202
219
  if (health) alerts.push(health);
203
- // 父链快照是 stampOwner 的按需成本:只有开关确实开启时才拍,总览页也
204
- // 才能顺带显示「认亲退化」这第四枚告警。
205
- const [consumers, registryAlert] =
206
- await deps.scanSessions({ lineage: cfg?.stampOwner === true });
220
+ // 祖先链恒拍:总览要同时给出逐任务的活创建者/预热承接判定。
221
+ const [, registryAlert, details] = await deps.scanSessions();
207
222
  if (registryAlert) alerts.push(registryAlert);
208
223
  const state = loadState();
209
224
  const curOv = Math.floor(Date.now() / 1000);
210
225
  const heartbeat = loadHeartbeat();
211
- let coolingWarned = new Set();
212
- let discovered = null;
213
226
 
214
227
  if (cfgError !== null) {
215
- // 页面不能死在半路:损坏或字段类型不对的配置是「需要留意」的一种;巡
216
- // 检本身仍会以退出码 2 失败(loadConfig 与这里共用同一套校验)。
228
+ // 页面不能死在半路:损坏配置是「需要留意」的一种;巡检本身仍会以退
229
+ // 出码 2 失败。
217
230
  deps.print(`配置:无法生效(${cfgPath})`);
218
231
  alerts.push(`配置无法生效(巡检将以退出码 2 失败):${cfgError.message}`);
219
232
  } else if (cfg === null) {
220
233
  deps.print('配置:缺失,运行 cron-up install 生成');
221
234
  } else {
222
- const actualIv = info ? info.interval : null;
223
- const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
224
- const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
225
- const retainTag = cfg.sessionRetain === 'always'
226
- ? ',会话常驻' : ',会话按执行回收';
227
- const stampTag = cfg.stampOwner ? ',任务归属预热' : '';
228
- if (actualIv == null) {
229
- deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
230
- + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}${stampTag}`);
231
- } else {
232
- deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
233
- + `会话${renewTag}${minIdTag}${retainTag}${stampTag}`);
234
- if (actualIv !== cfg.intervalSeconds) {
235
- alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
236
- + '秒不一致,重新运行 cron-up install 后生效');
237
- }
235
+ deps.print(`巡检:每 ${INTERVAL_SECONDS} 秒一轮,提前 ${LEAD_SECONDS} `
236
+ + '秒为每个窗口内任务启动专属预热会话');
237
+ if (info && info.interval !== null && info.interval !== undefined
238
+ && info.interval !== INTERVAL_SECONDS) {
239
+ alerts.push(`launchd 实际间隔 ${info.interval} 秒与当前版本的 `
240
+ + `${INTERVAL_SECONDS} 秒不一致,重新运行 cron-up install 后生效`);
238
241
  }
239
- const suffix = `(深度 ${cfg.maxDepth})`;
240
242
  const rootsText = cfg.roots.join('、');
241
- const prefix = '扫描:';
242
- deps.print(prefix
243
- + clip(rootsText, cols - dispWidth(prefix) - dispWidth(suffix))
244
- + suffix);
245
- // 心跳回答「巡检最近真的在跑吗」:launchd 已加载但启动脚本运行期失
246
- // 效时,静态健康检查(launcherHealth)全绿而轮次早已停摆,只有沉默的
247
- // 心跳能戳穿。服务未加载(info 为 null)时不告警,服务行已说明未安装。
243
+ deps.print(`扫描:${clip(rootsText, cols - dispWidth('扫描:'))}`);
244
+ // 心跳回答「巡检最近真的在跑吗」:服务已加载但启动链运行期失效时,
245
+ // 只有沉默的心跳能戳穿。
248
246
  if (heartbeat) {
249
247
  deps.print(`上次巡检:${elapsedZh(curOv - heartbeat.ranAt)}前`);
250
- if (info && heartbeatStale(heartbeat, cfg.intervalSeconds, curOv)) {
248
+ if (info && heartbeatStale(heartbeat, INTERVAL_SECONDS, curOv)) {
251
249
  alerts.push(`巡检已沉默 ${elapsedZh(curOv - heartbeat.ranAt)}:launchd `
252
- + `显示服务已加载(配置每 ${cfg.intervalSeconds} 秒一轮),但最近没有`
253
- + '成功跑完的轮次,启动链可能已失效;请运行 cron-up logs 查看巡检'
254
- + '日志,必要时重跑 cron-up install');
250
+ + `显示服务已加载,但最近没有成功跑完的轮次,启动链可能已失效;`
251
+ + '请运行 cron-up logs 查看巡检日志,必要时重跑 cron-up install');
255
252
  }
256
253
  }
257
254
  alerts.push(...validateConfig(cfg));
258
255
  deps.print('');
259
- const [taskAlerts, coolingWarnedNow, found] = overviewTasks(
260
- cfg, cols, consumers, state);
256
+ const [taskAlerts, coolingWarned, found] = overviewTasks(
257
+ cfg, cols, details, state);
261
258
  alerts.push(...taskAlerts);
262
- coolingWarned = coolingWarnedNow;
263
- discovered = found;
259
+ // 会话区需要上面产出的冷却抑制集合。
260
+ alerts.push(...overviewSessions(cols, state, details, coolingWarned, found));
264
261
  }
265
262
 
266
- // 会话状态来自 state.json,与配置无关:未安装/配置损坏时也可能有残留保
267
- // 活会话,照常展示。
268
- alerts.push(...overviewSessions(cols, state, coolingWarned, discovered));
263
+ // 未安装/配置损坏时也可能有残留预热会话,照常展示(没有 discovered
264
+ // 上下文,不做「移出 roots」告警)。
265
+ if (cfg === null || cfgError !== null) {
266
+ alerts.push(...overviewSessions(cols, state, details, new Set(), null));
267
+ }
269
268
 
270
269
  if (alerts.length) {
271
270
  deps.print('需要留意:');