cron-up 26.10.30 → 26.10.31
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 +111 -176
- package/package.json +1 -1
- package/src/args.mjs +5 -14
- package/src/cli.mjs +12 -29
- package/src/config.mjs +90 -66
- package/src/constants.mjs +24 -44
- package/src/list.mjs +49 -22
- package/src/logs.mjs +36 -24
- package/src/overview.mjs +108 -109
- package/src/patrol.mjs +445 -429
- package/src/registry.mjs +78 -80
- package/src/service.mjs +89 -166
- package/src/sessions.mjs +34 -17
- package/src/tasks.mjs +73 -51
package/src/config.mjs
CHANGED
|
@@ -62,48 +62,21 @@ export function parsePid(v) {
|
|
|
62
62
|
return pid > 0 && pid <= PID_T_MAX ? pid : null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
65
|
+
// 配置只有 roots 一个字段:手改写坏它时绝不静默回退——回退成空数组是
|
|
66
|
+
// 「形同空转」,回退成默认家目录扫描则会动到用户没打算纳入的仓库,两个
|
|
67
|
+
// 方向都比停机更危险。
|
|
68
68
|
export function configFieldErrors(cfg) {
|
|
69
|
-
const errs = [];
|
|
70
69
|
if (!Array.isArray(cfg.roots)) {
|
|
71
|
-
|
|
70
|
+
return [['roots', `roots 应是字符串数组,当前为 ${JSON.stringify(cfg.roots)}`]];
|
|
72
71
|
}
|
|
73
|
-
|
|
74
|
-
errs.push(['maxDepth', `maxDepth 应是非负整数,当前为 ${JSON.stringify(cfg.maxDepth)}`]);
|
|
75
|
-
}
|
|
76
|
-
if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
|
|
77
|
-
errs.push(['intervalSeconds',
|
|
78
|
-
`intervalSeconds 应是正整数秒,当前为 ${JSON.stringify(cfg.intervalSeconds)}`]);
|
|
79
|
-
}
|
|
80
|
-
if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
|
|
81
|
-
errs.push(['leadSeconds',
|
|
82
|
-
`leadSeconds 应是非负整数秒,当前为 ${JSON.stringify(cfg.leadSeconds)}`]);
|
|
83
|
-
}
|
|
84
|
-
if (typeof cfg.autoRenew !== 'boolean') {
|
|
85
|
-
errs.push(['autoRenew',
|
|
86
|
-
`autoRenew 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoRenew)}`]);
|
|
87
|
-
}
|
|
88
|
-
if (typeof cfg.autoMinId !== 'boolean') {
|
|
89
|
-
errs.push(['autoMinId',
|
|
90
|
-
`autoMinId 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoMinId)}`]);
|
|
91
|
-
}
|
|
92
|
-
if (cfg.sessionRetain !== 'window' && cfg.sessionRetain !== 'always') {
|
|
93
|
-
errs.push(['sessionRetain',
|
|
94
|
-
`sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
|
|
95
|
-
}
|
|
96
|
-
if (typeof cfg.stampOwner !== 'boolean') {
|
|
97
|
-
errs.push(['stampOwner',
|
|
98
|
-
`stampOwner 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.stampOwner)}`]);
|
|
99
|
-
}
|
|
100
|
-
return errs;
|
|
72
|
+
return [];
|
|
101
73
|
}
|
|
102
74
|
|
|
103
75
|
// opts.missingOk:缺失文件把异常抛给调用方(总览页把「缺失」当页面内容的
|
|
104
|
-
// 一部分);opts.corruptOk:损坏 JSON
|
|
105
|
-
// exit(总览页把它们当页面内容展示)。launchd 的 run
|
|
106
|
-
//
|
|
76
|
+
// 一部分);opts.corruptOk:损坏 JSON/顶层不是对象/roots 类型错抛普通错
|
|
77
|
+
// 误而非 exit(总览页把它们当页面内容展示)。launchd 的 run 入口两个都不
|
|
78
|
+
// 要——它该死成一行原因而不是 traceback。配置里的其余键(旧版本留下的
|
|
79
|
+
// intervalSeconds 等)一律忽略:行为已全部常量化,install 重写时纯化。
|
|
107
80
|
export function loadConfig(file, opts = {}) {
|
|
108
81
|
const fail = (message) => {
|
|
109
82
|
if (opts.corruptOk) throw new Error(message);
|
|
@@ -123,26 +96,67 @@ export function loadConfig(file, opts = {}) {
|
|
|
123
96
|
throw new ExitError(2, `配置文件不是合法 JSON:${file}\n ${e.message}`);
|
|
124
97
|
}
|
|
125
98
|
if (!isPlainObject(cfg)) {
|
|
126
|
-
// 合法 JSON 但顶层不是对象(123、["a"]):与「不是合法 JSON」同等对待。
|
|
127
|
-
// 单行消息:总览页按宽度截断展示,断行会失去告警前缀。
|
|
128
99
|
fail(`配置文件顶层应是 JSON 对象而不是${Array.isArray(cfg) ? '数组' : typeof cfg}:`
|
|
129
100
|
+ `${file};运行 cron-up install 生成,或用 --config 指定路径`);
|
|
130
101
|
}
|
|
131
102
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
132
103
|
const errs = configFieldErrors(merged);
|
|
133
104
|
if (errs.length > 0) {
|
|
134
|
-
// 单行消息(字段在前、修复指引在后):总览页按宽度截断展示,截尾只
|
|
135
|
-
// 伤修复指引,不伤字段清单。
|
|
136
105
|
fail(`配置字段类型不对:${errs.map(([, m]) => m).join(';')}`
|
|
137
|
-
+ `;修复 ${file} 或重跑 cron-up install
|
|
106
|
+
+ `;修复 ${file} 或重跑 cron-up install`);
|
|
107
|
+
}
|
|
108
|
+
return { roots: normalizeRoots(merged.roots) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- state.json:两级嵌套 {ws: {taskId: entry}} ----
|
|
112
|
+
//
|
|
113
|
+
// entry 字段:pid/procStart(script 层指纹)、claudePid/sessionId/
|
|
114
|
+
// claudeProcStart(认亲后回填)、startedAt、fails、cooldownUntil、
|
|
115
|
+
// deadSince、log、notified。pid 为 null 的条目是「当场没拉起/冷却中」的
|
|
116
|
+
// 合法形态;未认亲时 claude* 三字段缺省。
|
|
117
|
+
|
|
118
|
+
// 旧版顶层标记字段:旧 state 是 {ws: entry},新 state 是 {ws: {taskId:
|
|
119
|
+
// entry}}。taskId 只可能是 8 位十六进制或 anon: 前缀,永不与这些名字冲
|
|
120
|
+
// 突,因此「顶层值自身是否带 entry 标记」就是两代形状的区分判据。
|
|
121
|
+
const LEGACY_MARKERS = ['pid', 'startedAt', 'cooldownUntil', 'fails'];
|
|
122
|
+
|
|
123
|
+
function cleanEntry(raw, ws, taskId) {
|
|
124
|
+
if (!isPlainObject(raw)) return null;
|
|
125
|
+
const ent = { ...raw };
|
|
126
|
+
ent.pid = parsePid(ent.pid);
|
|
127
|
+
if (ent.claudePid !== undefined) {
|
|
128
|
+
const claudePid = parsePid(ent.claudePid);
|
|
129
|
+
if (claudePid === null) delete ent.claudePid;
|
|
130
|
+
else ent.claudePid = claudePid;
|
|
131
|
+
}
|
|
132
|
+
for (const k of ['startedAt', 'cooldownUntil', 'fails', 'deadSince']) {
|
|
133
|
+
if (ent[k] !== null && ent[k] !== undefined) {
|
|
134
|
+
try {
|
|
135
|
+
ent[k] = lenientInt(ent[k]);
|
|
136
|
+
} catch {
|
|
137
|
+
delete ent[k];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// 身份字符串:存在就必须是非空字符串,否则删掉(ws/taskId 随即用键补
|
|
142
|
+
// 回)。
|
|
143
|
+
for (const k of ['ws', 'taskId', 'sessionId', 'log', 'procStart',
|
|
144
|
+
'claudeProcStart']) {
|
|
145
|
+
if (ent[k] !== undefined
|
|
146
|
+
&& (typeof ent[k] !== 'string' || ent[k] === '')) {
|
|
147
|
+
delete ent[k];
|
|
148
|
+
}
|
|
138
149
|
}
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
if (typeof ent.notified !== 'boolean') delete ent.notified;
|
|
151
|
+
ent.ws = ws;
|
|
152
|
+
ent.taskId = taskId;
|
|
153
|
+
return ent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function looksLegacyEntry(v) {
|
|
157
|
+
return isPlainObject(v) && LEGACY_MARKERS.some((k) => k in v);
|
|
141
158
|
}
|
|
142
159
|
|
|
143
|
-
// 自己的文件也可能被手改或截断:非对象读成「没有 state」,数值字段入口处
|
|
144
|
-
// 整型化并卡范围——否则 kill 收到字符串会抛、负数 pid 会被当成进程组号
|
|
145
|
-
// 误伤无辜进程组、超 pid_t 上界的整数直到 kill 才炸。
|
|
146
160
|
export function loadState() {
|
|
147
161
|
let state;
|
|
148
162
|
try {
|
|
@@ -152,20 +166,38 @@ export function loadState() {
|
|
|
152
166
|
}
|
|
153
167
|
if (!isPlainObject(state)) return {};
|
|
154
168
|
const out = {};
|
|
169
|
+
for (const [ws, bucket] of Object.entries(state)) {
|
|
170
|
+
// 旧版顶层 entry 不是桶:跳过(由 loadLegacyEntries 收口处理)。
|
|
171
|
+
if (!isPlainObject(bucket) || looksLegacyEntry(bucket)) continue;
|
|
172
|
+
const cleanBucket = {};
|
|
173
|
+
for (const [taskId, raw] of Object.entries(bucket)) {
|
|
174
|
+
const ent = cleanEntry(raw, ws, taskId);
|
|
175
|
+
if (ent) cleanBucket[taskId] = ent;
|
|
176
|
+
}
|
|
177
|
+
if (Object.keys(cleanBucket).length > 0) out[ws] = cleanBucket;
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 旧版扁平 state({ws: 目录级单 entry}):升级轮用它停掉旧预热后整体丢
|
|
183
|
+
// 弃,不迁移任务归属(新流程按任务重拉)。与 loadState 读同一个文件,
|
|
184
|
+
// 两个读取器按标记字段互斥,互不污染。
|
|
185
|
+
export function loadLegacyEntries() {
|
|
186
|
+
let state;
|
|
187
|
+
try {
|
|
188
|
+
state = JSON.parse(fs.readFileSync(deps.paths.statePath, 'utf-8'));
|
|
189
|
+
} catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
if (!isPlainObject(state)) return [];
|
|
193
|
+
const out = [];
|
|
155
194
|
for (const [ws, raw] of Object.entries(state)) {
|
|
156
|
-
if (!
|
|
157
|
-
const ent =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
ent[k] = lenientInt(ent[k]);
|
|
163
|
-
} catch {
|
|
164
|
-
delete ent[k];
|
|
165
|
-
}
|
|
166
|
-
}
|
|
195
|
+
if (!looksLegacyEntry(raw)) continue;
|
|
196
|
+
const ent = cleanEntry(raw, ws, null);
|
|
197
|
+
if (ent) {
|
|
198
|
+
delete ent.taskId; // 旧 entry 没有任务身份
|
|
199
|
+
out.push(ent);
|
|
167
200
|
}
|
|
168
|
-
out[ws] = ent;
|
|
169
201
|
}
|
|
170
202
|
return out;
|
|
171
203
|
}
|
|
@@ -238,14 +270,6 @@ export function validateConfig(cfg, announce = () => {}) {
|
|
|
238
270
|
if (!cfg.roots || cfg.roots.length === 0) {
|
|
239
271
|
warnings.push('扫描目录 roots 为空:巡检不会发现任何工作区,cron-up 形同空转');
|
|
240
272
|
}
|
|
241
|
-
const interval = cfg.intervalSeconds;
|
|
242
|
-
const lead = cfg.leadSeconds;
|
|
243
|
-
if (isInt(interval) && isInt(lead) && interval > 0 && lead >= 0
|
|
244
|
-
&& lead < interval) {
|
|
245
|
-
warnings.push(
|
|
246
|
-
`leadSeconds=${lead} 小于 intervalSeconds=${interval}:任务可能在`
|
|
247
|
-
+ '两轮巡检之间到期而来不及提前启动会话,建议 lead ≥ interval');
|
|
248
|
-
}
|
|
249
273
|
for (const root of cfg.roots ?? []) {
|
|
250
274
|
if (!isDir(root)) warnings.push(`扫描目录不存在,巡检不会覆盖它:${root}`);
|
|
251
275
|
}
|
package/src/constants.mjs
CHANGED
|
@@ -1,41 +1,25 @@
|
|
|
1
1
|
// 全部调参常量集中一处,改动时便于通盘核对。
|
|
2
2
|
|
|
3
|
+
// 配置文件只有 roots 一个字段:巡检间隔、提前量、扫描深度、续期、改 id、
|
|
4
|
+
// 任务归属盖印都是不可关闭的唯一行为(见 README),常量化在本文件。
|
|
3
5
|
export const DEFAULT_CONFIG = {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 盖;目录不存在则跳过。
|
|
6
|
+
// 默认只扫本人的三个任务制容器,不扫整个家目录——他人可写仓库里的周期
|
|
7
|
+
// 任务会被自动续期永久化,默认范围必须是用户完全掌控的领地(见 README
|
|
8
|
+
// 安全说明)。其他布局用 cron-up install --roots 覆盖;目录不存在则跳过。
|
|
8
9
|
roots: ['~/space', '~/workspace', '~/tasks'],
|
|
9
|
-
maxDepth: 2,
|
|
10
|
-
intervalSeconds: 300,
|
|
11
|
-
leadSeconds: 600,
|
|
12
|
-
// 每轮巡检给周期任务补 permanent 标记,绕开 Claude Code 的 7 天周期任务
|
|
13
|
-
// 过期。loadConfig 会用默认值补缺键,所以安装或升级后的首轮会把旧任务
|
|
14
|
-
// 全部补标。配置里 autoRenew:false 可关(下轮生效),或安装时
|
|
15
|
-
// --no-auto-renew。
|
|
16
|
-
autoRenew: true,
|
|
17
|
-
// 每轮巡检把周期任务的 id 改写成小哈希值,把投递抖动压到
|
|
18
|
-
// AUTO_MIN_ID_TARGET_DELAY_MS 以内(每日任务约 1 分钟),观感近乎准点。
|
|
19
|
-
// 只改显式周期任务、只分配文件内空号、幂等。配置 autoMinId:false 可关,
|
|
20
|
-
// 或安装时 --no-auto-min-id。机制见 CC_JITTER 与 findings 主题
|
|
21
|
-
// claude-code-scheduled-task-jitter。
|
|
22
|
-
autoMinId: true,
|
|
23
|
-
// 会话保留策略:'window'(默认)——会话生命周期对齐「一次执行」:任务
|
|
24
|
-
// 触发过、执行完毕且距下次需要还有余量即回收,下次窗口重拉,任务之间
|
|
25
|
-
// 上下文不互相累积;'always'——旧常驻行为,执行间隔里也保留会话,仅受
|
|
26
|
-
// 7 天超龄换代约束。
|
|
27
|
-
sessionRetain: 'window',
|
|
28
|
-
// 任务归属改写:默认关,维持「同目录有会话就不双开」的旧行为。开启后,
|
|
29
|
-
// 即使用户本人的交互会话在场,也另拉独立预热会话,并把投递窗口内任务的
|
|
30
|
-
// createdBySessionId/createdByPid/createdByProcStart 三字段改写成预热会话
|
|
31
|
-
// ——任务按 Claude Code 自身的「创建者会话铁拿」规则投到预热会话,用户
|
|
32
|
-
// 会话主动不碰;预热死亡则自动滑回原生的死者接管规则。依赖 Claude Code
|
|
33
|
-
// 未公开的任务文件字段与进程父子关系,见 README「任务归属预热」。
|
|
34
|
-
stampOwner: false,
|
|
35
10
|
};
|
|
36
11
|
|
|
12
|
+
// launchd 两轮巡检之间的秒数(plist 的 StartInterval)。
|
|
13
|
+
export const INTERVAL_SECONDS = 300;
|
|
14
|
+
// 提前多少秒为窗口内任务备好会话:覆盖两轮巡检(本轮发现、下轮盖印),任
|
|
15
|
+
// 何任务最迟在执行前一轮被发现;会话登记本身只需约 10 秒。
|
|
16
|
+
export const LEAD_SECONDS = 600;
|
|
17
|
+
// 工作区发现的最大递归深度(相对 roots):深度 3 覆盖
|
|
18
|
+
// <容器>/<仓>/<子目录>/<孙目录> 里的任务文件。
|
|
19
|
+
export const MAX_SCAN_DEPTH = 3;
|
|
20
|
+
|
|
37
21
|
// 预热会话连续三次启动后死亡,大概率是环境坏了(缺信任、env 不对),退避
|
|
38
|
-
//
|
|
22
|
+
// 而不是每轮无限重拉。
|
|
39
23
|
export const FAIL_LIMIT = 3;
|
|
40
24
|
export const COOLDOWN_SECONDS = 1800;
|
|
41
25
|
// 失败计数随死条目保留的时长。提前窗口通常只覆盖一两轮巡检(每小时任务
|
|
@@ -50,20 +34,16 @@ export const PID_T_MAX = 2 ** 31 - 1;
|
|
|
50
34
|
// 健康会话约 10 秒内完成登记。超时仍存活却没登记,是卡在无人应答的提问
|
|
51
35
|
// 界面(目录信任、工具授权);只看存活会把它当健康,只有这个时限兜得住。
|
|
52
36
|
export const WARMUP_GRACE_SECONDS = 180;
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
|
|
57
|
-
export const SESSION_IDLE_SECONDS = 3600;
|
|
58
|
-
// window 模式回收判据一:界面静默超过此时长视为「本次执行已结束」。TUI
|
|
59
|
-
// 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;取
|
|
60
|
-
// WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智(调一个跟着
|
|
61
|
-
// 动)。
|
|
37
|
+
// window 回收判据一:界面静默超过此时长视为「本任务已执行完」。TUI 工作
|
|
38
|
+
// 时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;取
|
|
39
|
+
// WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智。
|
|
62
40
|
export const SESSION_RECYCLE_IDLE_SECONDS = WARMUP_GRACE_SECONDS;
|
|
63
|
-
// window
|
|
64
|
-
//
|
|
65
|
-
//
|
|
41
|
+
// window 回收判据二:距该任务下次「需要会话在场」至少还有这么多秒。回收
|
|
42
|
+
// 后要经「下轮重拉 → 新会话登记 → 再下轮盖印」两步,任务才重新铁定投到
|
|
43
|
+
// 新预热:一个巡检间隔加登记抖动余量。更近的触发不回收,复用现有会话。
|
|
66
44
|
export const SESSION_FIRE_MARGIN_SECONDS = 120;
|
|
45
|
+
export const SESSION_RECYCLE_MARGIN_SECONDS =
|
|
46
|
+
SESSION_FIRE_MARGIN_SECONDS + INTERVAL_SECONDS;
|
|
67
47
|
// 巡检日志每轮两行心跳加零星事件,跑几年的机器也该有上限。launchd 每次按
|
|
68
48
|
// 路径重开,轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
|
|
69
49
|
export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
|
|
@@ -82,7 +62,7 @@ export const PRUNE_DIRS = new Set([
|
|
|
82
62
|
|
|
83
63
|
// Claude Code 会话投递定时任务时的抖动参数,两处使用:list/总览预测
|
|
84
64
|
// 「预计实际触发时间」;巡检把「落点之后、延迟投递点之前」这段投递尾窗
|
|
85
|
-
//
|
|
65
|
+
// 也算作需要会话在场,避免在任务真正投递前把预热回收。逆向自
|
|
86
66
|
// Claude Code v2.1.285
|
|
87
67
|
// (darwin-arm64 原生二进制,2026-09-30 安装)内部 createCronScheduler 的
|
|
88
68
|
// gV 默认值:周期任务 fire = 落点 + min(hash(id)·recurringFrac·周期,
|
package/src/list.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// `cron-up list
|
|
2
|
-
//
|
|
1
|
+
// `cron-up list`:逐工作区列出任务的下次触发、节奏、类型、摘要与该任务
|
|
2
|
+
// 的预热状态。
|
|
3
3
|
|
|
4
4
|
import { deps } from './internals.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { LEAD_SECONDS } from './constants.mjs';
|
|
6
|
+
import { loadConfig, loadState, isCooling } from './config.mjs';
|
|
7
|
+
import { taskKey } from './tasks.mjs';
|
|
6
8
|
import {
|
|
7
9
|
clip,
|
|
8
10
|
pad,
|
|
@@ -25,16 +27,34 @@ function statusText(v) {
|
|
|
25
27
|
return fmtFireRange(v.nxt, v.fire);
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
// 单个任务的预热状态标签。创建者会话还开着时返回空串——任务原生投进创
|
|
31
|
+
// 建者,无需预热;其余按 entry 的派生阶段给短标签。
|
|
32
|
+
function warmTag(t, v, ent, liveSids, ws, cur) {
|
|
33
|
+
if (deps.creatorSessionAlive(t, liveSids)) return '';
|
|
34
|
+
if (ent) {
|
|
35
|
+
if (isCooling(ent, cur)) return '冷却中';
|
|
36
|
+
if (deps.trackedAlive(ent)) {
|
|
37
|
+
return ent.sessionId ? `预热中 ${ent.pid}` : `启动中 ${ent.pid}`;
|
|
38
|
+
}
|
|
39
|
+
if (typeof ent.sessionId === 'string'
|
|
40
|
+
&& (liveSids.has(ent.sessionId))) return '终端接管';
|
|
41
|
+
if ((ent.fails ?? 0) > 0) return `失败${ent.fails}次`;
|
|
42
|
+
}
|
|
43
|
+
return v.wanted ? '待预热' : '';
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export async function cmdList(args) {
|
|
29
47
|
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
30
48
|
const now = new Date();
|
|
49
|
+
const cur = Math.floor(now.getTime() / 1000);
|
|
31
50
|
const cols = deps.terminalWidth();
|
|
32
51
|
const blocks = [];
|
|
33
52
|
let total = 0;
|
|
34
53
|
let filesSeen = 0;
|
|
35
54
|
let unreadable = 0;
|
|
36
|
-
const [
|
|
37
|
-
|
|
55
|
+
const [, , details] = await deps.scanSessions();
|
|
56
|
+
const state = loadState();
|
|
57
|
+
for (const [ws] of deps.discover(cfg.roots)) {
|
|
38
58
|
filesSeen += 1;
|
|
39
59
|
const tasks = deps.readTasks(ws);
|
|
40
60
|
if (tasks === null) {
|
|
@@ -43,12 +63,21 @@ export async function cmdList(args) {
|
|
|
43
63
|
continue;
|
|
44
64
|
}
|
|
45
65
|
if (tasks.length === 0) continue;
|
|
46
|
-
const
|
|
66
|
+
const liveSids = deps.liveSessionIds(details, ws);
|
|
67
|
+
const views = tasks.map((t) => {
|
|
68
|
+
// 带提前量:「待预热」标签与巡检的预热窗口同一口径。
|
|
69
|
+
const v = taskView(t, now, LEAD_SECONDS * 1000);
|
|
70
|
+
return {
|
|
71
|
+
v,
|
|
72
|
+
t,
|
|
73
|
+
tag: warmTag(t, v, state[ws]?.[taskKey(t)], liveSids, ws, cur),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
47
76
|
// 错过待补执行排最前,然后按下次时间升序,一年内无安排的垫底,无效
|
|
48
77
|
// 的最后。
|
|
49
|
-
views.sort((a, b) => rank(a) - rank(b)
|
|
50
|
-
|| ((a.nxt?.getTime() ?? Infinity) - (b.nxt?.getTime() ?? Infinity)));
|
|
51
|
-
blocks.push([ws,
|
|
78
|
+
views.sort((a, b) => rank(a.v) - rank(b.v)
|
|
79
|
+
|| ((a.v.nxt?.getTime() ?? Infinity) - (b.v.nxt?.getTime() ?? Infinity)));
|
|
80
|
+
blocks.push([ws, views]);
|
|
52
81
|
total += tasks.length;
|
|
53
82
|
}
|
|
54
83
|
|
|
@@ -68,23 +97,17 @@ export async function cmdList(args) {
|
|
|
68
97
|
blocks.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
69
98
|
deps.print(`共 ${blocks.length} 个工作区、${total} 个定时任务`);
|
|
70
99
|
|
|
71
|
-
//
|
|
72
|
-
// 改文案不会静默错位;cadence 是截断列,取固定上限。
|
|
100
|
+
// 列宽从全部实际文案动态算;cadence 是截断列,取固定上限。
|
|
73
101
|
const statusW = Math.max(11,
|
|
74
|
-
...blocks.flatMap(([,
|
|
102
|
+
...blocks.flatMap(([, views]) => views.map(({ v }) => dispWidth(statusText(v)))));
|
|
75
103
|
const kindW = Math.max(...['一次性', '周期', '周期·永久'].map(dispWidth)) + 1;
|
|
76
104
|
const cadW = 16;
|
|
77
|
-
//
|
|
78
|
-
// " " + summaryW。空间紧张时摘要先让位;为 0 则整段省略——状态箭头在
|
|
79
|
-
// 60 列下限下本身就可能占 25 列(跨天),不能再用 12 列摘要下限把行撑爆。
|
|
105
|
+
// 固定列:" " status " " kind " " cadence " " summary [" " tag]。
|
|
80
106
|
const fixedW = 2 + statusW + 2 + kindW + 2 + cadW;
|
|
81
|
-
const
|
|
82
|
-
for (const [ws, consumer, views] of blocks) {
|
|
107
|
+
for (const [ws, views] of blocks) {
|
|
83
108
|
deps.print('');
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
deps.print(`${pad(clip(ws, pathW), pathW)} ${label}`);
|
|
87
|
-
for (const v of views) {
|
|
109
|
+
deps.print(clip(ws, cols));
|
|
110
|
+
for (const { v, tag } of views) {
|
|
88
111
|
let kind;
|
|
89
112
|
let cadence;
|
|
90
113
|
let summary;
|
|
@@ -97,11 +120,15 @@ export async function cmdList(args) {
|
|
|
97
120
|
cadence = v.cadence;
|
|
98
121
|
summary = v.summary;
|
|
99
122
|
}
|
|
123
|
+
// 给预热标签预留宽度(含括号与间隔);极窄屏摘要可以截到 0。
|
|
124
|
+
const tagPart = tag ? ` [${tag}]` : '';
|
|
125
|
+
const summaryW = Math.max(0,
|
|
126
|
+
cols - fixedW - 2 - dispWidth(tagPart));
|
|
100
127
|
let line = ' ' + pad(statusText(v), statusW) + ' '
|
|
101
128
|
+ pad(kind, kindW) + ' '
|
|
102
129
|
+ pad(clip(cadence, cadW), cadW);
|
|
103
|
-
// 极窄屏摘要列算到 0:整段省略(含前导间隔),保证行宽不超终端。
|
|
104
130
|
if (summaryW > 0) line += ' ' + clip(summary, summaryW);
|
|
131
|
+
line += tagPart;
|
|
105
132
|
deps.print(line);
|
|
106
133
|
}
|
|
107
134
|
}
|
package/src/logs.mjs
CHANGED
|
@@ -1,40 +1,52 @@
|
|
|
1
|
-
// `cron-up logs`:选日志文件并交给 tail
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// `cron-up logs`:选日志文件并交给 tail。默认看巡检日志(它回答「最近
|
|
2
|
+
// 怎么样」),stderr 只是崩溃收集处,不能因为一次旧故障盖住此后所有健
|
|
3
|
+
// 康的巡检轮次。一个工作区现在有多个任务级日志,按前缀匹配,多义时列
|
|
4
|
+
// 候选让人给更长的片段。
|
|
4
5
|
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
|
|
8
9
|
import { deps, ExitError } from './internals.mjs';
|
|
9
10
|
import { onDiskCase, lexReal } from './tasks.mjs';
|
|
10
|
-
import {
|
|
11
|
+
import { slugSegment } from './sessions.mjs';
|
|
11
12
|
import { fmtMDHM } from './display.mjs';
|
|
12
13
|
|
|
14
|
+
// 列出会话日志目录下当前一代(.log,不含 .log.1)的全部日志绝对路径。
|
|
15
|
+
function listCurrentLogs() {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readdirSync(deps.paths.sessionLogDir)
|
|
18
|
+
.filter((f) => f.endsWith('.log'))
|
|
19
|
+
.map((f) => path.join(deps.paths.sessionLogDir, f));
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 多个候选时打印清单并放弃(exit 1),tail 哪一个由用户补全片段决定。
|
|
26
|
+
function chooseOne(matches, say) {
|
|
27
|
+
if (matches.length === 1) return matches[0];
|
|
28
|
+
say('匹配到多个任务级会话日志,请给出更长的片段(如 目录名--任务id):');
|
|
29
|
+
for (const f of matches.sort()) say(` ${path.basename(f)}`);
|
|
30
|
+
throw new ExitError(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export function resolveLogPath(workspace, announce) {
|
|
14
34
|
const say = announce ?? ((m) => deps.printErr(m));
|
|
15
35
|
if (workspace) {
|
|
16
36
|
if (path.isAbsolute(workspace)) {
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let matches = [];
|
|
25
|
-
try {
|
|
26
|
-
matches = fs.readdirSync(deps.paths.sessionLogDir)
|
|
27
|
-
.filter((f) => f.endsWith('.log'))
|
|
28
|
-
.map((f) => path.join(deps.paths.sessionLogDir, f))
|
|
29
|
-
.filter((f) => path.basename(f).includes(workspace));
|
|
30
|
-
} catch {
|
|
31
|
-
matches = [];
|
|
32
|
-
}
|
|
33
|
-
if (matches.length > 1) {
|
|
34
|
-
say(`'${workspace}' 匹配到多个会话日志,请给出更长的片段:`);
|
|
35
|
-
for (const f of matches.sort()) say(` ${path.basename(f)}`);
|
|
36
|
-
throw new ExitError(1);
|
|
37
|
+
// 日志名由工作区路径折成前缀,任务 id 是后缀:敲绝对路径(目录)
|
|
38
|
+
// 时按 <slug(ws)>--*.log 前缀匹配,认回磁盘真实大小写。
|
|
39
|
+
const ws = onDiskCase(lexReal(workspace));
|
|
40
|
+
const prefix = `${slugSegment(ws)}--`;
|
|
41
|
+
const matches = listCurrentLogs().filter((f) =>
|
|
42
|
+
path.basename(f).startsWith(prefix));
|
|
43
|
+
return matches.length === 0 ? null : chooseOne(matches, say);
|
|
37
44
|
}
|
|
45
|
+
// 片段匹配:直接对文件名做 includes,天然覆盖「目录名」「目录名--
|
|
46
|
+
// 任务id」「任务id」三种给法。
|
|
47
|
+
const matches = listCurrentLogs()
|
|
48
|
+
.filter((f) => path.basename(f).includes(workspace));
|
|
49
|
+
if (matches.length > 1) return chooseOne(matches, say);
|
|
38
50
|
return matches[0] ?? null;
|
|
39
51
|
}
|
|
40
52
|
|