@mzzsfy/dsh-cron-board 0.4.2 → 0.5.0
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 +5 -0
- package/package.json +1 -1
- package/src/api.mjs +2 -0
- package/src/client.js +107 -21
- package/src/cron.mjs +45 -19
- package/src/schedule-builder.mjs +48 -5
- package/test/cron.test.mjs +98 -0
- package/test/executor.test.mjs +22 -19
- package/test/index.test.mjs +2 -1
- package/test/routes.test.mjs +29 -2
- package/test/schedule-builder.test.mjs +49 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- **环境变量**:集中管理,值打码展示,多值(同名展开笛卡尔积,组合数超出 20 截断并告警),dotenv 文本导入(预览/追加/覆盖)与导出
|
|
11
11
|
- **会话任务**:投递即终态(crontab 语义,执行状态归会话本身);fresh 每次新建会话;pinned 固定会话(忙时跳过,会话被删除或归档自动重建并回写,首跑自动绑定);按 workdir 自动挂载宿主分组,界面分组内可见;执行预设可选(Agent Preset,缺省跟随宿主默认,dsh-im 同构)
|
|
12
12
|
- **调度**:croner 解析,停机期间错过不补跑(misfire 即跳过),孤儿运行恢复,全局与任务级并发闸门;shell 任务超时收尾(会话任务无超时语义);单次运行开关(runOnce,任意一次真实运行入队后自动停用任务)
|
|
13
|
+
- **时区**:cron 表达式支持内联时区后缀 `T±N`(整数小时,如 `0 9 * * *T+8` 表示东八区 9 点,范围 -12 至 +14,`T+0` 即 UTC);构建器提供时区行写入后缀,新建任务默认固化设备时区偏移;无后缀表达式按宿主进程系统时区调度(crontab 惯例),摘要与运行记录按任务所用时区展示;固定偏移不随夏令时变化,半时区设备的设备偏移就近取整
|
|
13
14
|
- **日志**:每任务每运行独立日志文件,面板查看与清空,按任务保留条数裁剪(按文件修改时间)
|
|
14
15
|
|
|
15
16
|
## 路由
|
|
@@ -51,3 +52,7 @@ node --test "test/*.test.mjs" # 测试
|
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
注意:插件新增进入 profile bundles(`dsh plugin add`)后需 dsh 重启一次完成首装载;此后工作副本改动经 dev-link HMR 热重载。
|
|
55
|
+
|
|
56
|
+
## dsh 版本兼容
|
|
57
|
+
|
|
58
|
+
三版本全部通过:cron 看板渲染与启停、与侧边栏联动(侧栏标签页形态)、激活 live。0.1.6 新侧栏标签页系统下联动开关双态均验证通过。
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -281,6 +281,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
|
|
|
281
281
|
session: sessionState
|
|
282
282
|
? { ready: Boolean(sessionState.ready), disabled: Boolean(sessionState.disabled) }
|
|
283
283
|
: { ready: false, disabled: false },
|
|
284
|
+
// 宿主进程时区偏移小时数:无后缀任务的展示时区(调度与展示同源,与浏览器无关)
|
|
285
|
+
serverTzOffset: -new Date().getTimezoneOffset() / 60,
|
|
284
286
|
ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false },
|
|
285
287
|
})
|
|
286
288
|
},
|
package/src/client.js
CHANGED
|
@@ -34,10 +34,18 @@ function relativeTime(timestamp, now) {
|
|
|
34
34
|
return Math.round(abs / DAY_MS) + ' 天' + suffix
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function formatDateTime(timestamp) {
|
|
37
|
+
function formatDateTime(timestamp, timezoneOffset) {
|
|
38
38
|
if (typeof timestamp !== 'number' || !(timestamp > 0)) return '-'
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// 按调度所用时区(任务显式后缀或宿主默认)平移后取 UTC 分量,与浏览器时区无关;
|
|
40
|
+
// 偏移缺失(状态未加载)瞬态回退浏览器时区
|
|
41
|
+
const offset = typeof timezoneOffset === 'number' ? timezoneOffset : -new Date().getTimezoneOffset() / 60
|
|
42
|
+
const shifted = new Date(timestamp + offset * HOUR_MS)
|
|
43
|
+
return shifted.getUTCFullYear() + '-' + PAD2(shifted.getUTCMonth() + 1) + '-' + PAD2(shifted.getUTCDate()) + ' ' + PAD2(shifted.getUTCHours()) + ':' + PAD2(shifted.getUTCMinutes())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 浏览器所在设备的时区偏移(整小时就近取整):仅用于新建任务时固化初始时区后缀
|
|
47
|
+
function deviceTzOffset() {
|
|
48
|
+
return Math.round(-new Date().getTimezoneOffset() / 60)
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function formatDuration(ms) {
|
|
@@ -61,6 +69,9 @@ const SB_WEEKDAY_LABELS = { '0': '日', '1': '一', '2': '二', '3': '三', '4':
|
|
|
61
69
|
const SB_MINUTE_MAX = 59
|
|
62
70
|
const SB_HOUR_MAX = 23
|
|
63
71
|
const SB_INTERVAL_MIN_MINUTES = 1
|
|
72
|
+
const SB_TZ_SUFFIX_MIN = -12
|
|
73
|
+
const SB_TZ_SUFFIX_MAX = 14
|
|
74
|
+
const SB_TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
|
|
64
75
|
|
|
65
76
|
function createScheduleState(expression) {
|
|
66
77
|
return {
|
|
@@ -70,6 +81,7 @@ function createScheduleState(expression) {
|
|
|
70
81
|
weekdays: ['1'],
|
|
71
82
|
intervalMinutes: 30,
|
|
72
83
|
expression,
|
|
84
|
+
timezone: null,
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -79,11 +91,21 @@ const sbClampInt = (raw, min, max, fallback) => {
|
|
|
79
91
|
return value
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom
|
|
94
|
+
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
|
|
95
|
+
// 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
|
|
83
96
|
function parseSchedule(expression) {
|
|
84
97
|
const raw = String(expression).trim()
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
let split
|
|
99
|
+
try {
|
|
100
|
+
split = sbSplitScheduleTz(raw)
|
|
101
|
+
} catch {
|
|
102
|
+
const state = createScheduleState(raw)
|
|
103
|
+
state.freq = 'custom'
|
|
104
|
+
return state
|
|
105
|
+
}
|
|
106
|
+
const state = createScheduleState(split.base)
|
|
107
|
+
state.timezone = split.timezoneOffset
|
|
108
|
+
const parts = split.base.split(/\s+/)
|
|
87
109
|
if (parts.length !== 5) { state.freq = 'custom'; return state }
|
|
88
110
|
const [minute, hour, day, month, weekday] = parts
|
|
89
111
|
if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
|
|
@@ -111,8 +133,19 @@ function parseSchedule(expression) {
|
|
|
111
133
|
return state
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
//
|
|
136
|
+
// 偏移小时数 → 后缀符号值:+8 / -5 / +0
|
|
137
|
+
function sbFormatTzOffset(offset) {
|
|
138
|
+
return (offset >= 0 ? '+' : '') + offset
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
|
|
115
142
|
function buildSchedule(state) {
|
|
143
|
+
const base = sbBuildBase(state)
|
|
144
|
+
if (state.timezone == null) return base
|
|
145
|
+
return base + 'T' + sbFormatTzOffset(state.timezone)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sbBuildBase(state) {
|
|
116
149
|
if (state.freq === 'custom') return String(state.expression).trim()
|
|
117
150
|
const minute = String(state.minute)
|
|
118
151
|
const hour = String(state.hour)
|
|
@@ -122,8 +155,30 @@ function buildSchedule(state) {
|
|
|
122
155
|
if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
|
|
123
156
|
return String(state.expression).trim()
|
|
124
157
|
}
|
|
158
|
+
|
|
159
|
+
// 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
|
|
160
|
+
// 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
|
|
161
|
+
function sbSplitScheduleTz(schedule) {
|
|
162
|
+
const text = String(schedule).trim()
|
|
163
|
+
const match = text.match(SB_TZ_SUFFIX_PATTERN)
|
|
164
|
+
if (!match) return { base: text, timezoneOffset: null }
|
|
165
|
+
const offset = Number(match[1] + match[2])
|
|
166
|
+
if (offset < SB_TZ_SUFFIX_MIN || offset > SB_TZ_SUFFIX_MAX) {
|
|
167
|
+
throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + SB_TZ_SUFFIX_MIN + ' 至 ' + SB_TZ_SUFFIX_MAX + ')')
|
|
168
|
+
}
|
|
169
|
+
return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
|
|
170
|
+
}
|
|
125
171
|
/* SBUILD-END */
|
|
126
172
|
|
|
173
|
+
// 任务表达式的时区偏移(小时);无后缀返回 null;脏表达式(后缀非法)兜底 null 不炸渲染
|
|
174
|
+
function scheduleTzOffset(schedule) {
|
|
175
|
+
try {
|
|
176
|
+
return sbSplitScheduleTz(schedule).timezoneOffset
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
127
182
|
const API_PREFIX = '/api/cron-board/'
|
|
128
183
|
const REFRESH_INTERVAL_MS = 30 * SECOND_MS
|
|
129
184
|
const TABS = [
|
|
@@ -435,8 +490,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
435
490
|
{ value: 'custom', label: '自定义' },
|
|
436
491
|
]
|
|
437
492
|
|
|
438
|
-
// 调度构建器面板:频率分段 + 上下文选择器 + 表达式芯片 + 即将执行 chips
|
|
439
|
-
function ScheduleBuilder({ scheduleState, onStateChange, preview }) {
|
|
493
|
+
// 调度构建器面板:频率分段 + 上下文选择器 + 时区行 + 表达式芯片 + 即将执行 chips
|
|
494
|
+
function ScheduleBuilder({ scheduleState, onStateChange, preview, previewError, serverTzOffset }) {
|
|
440
495
|
const state = scheduleState
|
|
441
496
|
const setFreq = (freq) => onStateChange({ ...state, freq })
|
|
442
497
|
const patchClock = (field, raw) => {
|
|
@@ -450,6 +505,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
450
505
|
if (days.length === 0) return
|
|
451
506
|
onStateChange({ ...state, weekdays: days })
|
|
452
507
|
}
|
|
508
|
+
const setTz = (raw) => onStateChange({ ...state, timezone: raw === 'host' ? null : Number(raw) })
|
|
509
|
+
const displayTzOffset = state.timezone == null ? serverTzOffset : state.timezone
|
|
453
510
|
const clockRow = (h('div', { className: 'cb-bline' },
|
|
454
511
|
h('span', { className: 'cb-blabel' }, '执行时间'),
|
|
455
512
|
h('input', { type: 'number', className: 'cb-time', min: 0, max: SB_HOUR_MAX, value: state.hour, onChange: (e) => patchClock('hour', e.target.value) }),
|
|
@@ -486,14 +543,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
486
543
|
? null
|
|
487
544
|
: h('button', { type: 'button', className: 'cb-linkbtn', onClick: () => setFreq('custom') }, '自定义'),
|
|
488
545
|
state.freq === 'custom' ? null : h('span', { className: 'cb-hint' }, '由上方选择自动生成')),
|
|
546
|
+
h('div', { className: 'cb-bline' },
|
|
547
|
+
h('span', { className: 'cb-blabel' }, '时区'),
|
|
548
|
+
h('select', { className: 'cb-select', value: state.timezone == null ? 'host' : String(state.timezone), onChange: (e) => setTz(e.target.value) },
|
|
549
|
+
h('option', { value: 'host' }, '宿主默认' + (typeof serverTzOffset === 'number' ? '(UTC' + sbFormatTzOffset(serverTzOffset) + ')' : '')),
|
|
550
|
+
Array.from({ length: SB_TZ_SUFFIX_MAX - SB_TZ_SUFFIX_MIN + 1 }, (_, i) => SB_TZ_SUFFIX_MIN + i).map((offset) => h('option', { key: offset, value: String(offset) }, 'UTC' + sbFormatTzOffset(offset)))),
|
|
551
|
+
h('span', { className: 'cb-hint' }, '固定偏移不随夏令时变化;写入表达式后缀 T±N')),
|
|
489
552
|
h('div', { className: 'cb-bline' },
|
|
490
553
|
h('span', { className: 'cb-blabel' }, '即将执行'),
|
|
491
554
|
preview
|
|
492
|
-
? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at))))
|
|
493
|
-
: h('span', { className: 'cb-hint' }, '表达式非法或计算中')))
|
|
555
|
+
? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at, displayTzOffset))))
|
|
556
|
+
: h('span', { className: 'cb-hint' }, previewError || '表达式非法或计算中')))
|
|
494
557
|
}
|
|
495
558
|
|
|
496
|
-
function JobForm({ job, onDone, wsModel }) {
|
|
559
|
+
function JobForm({ job, onDone, wsModel, serverTzOffset }) {
|
|
497
560
|
// 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
|
|
498
561
|
const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
|
|
499
562
|
useEffect(() => {
|
|
@@ -503,7 +566,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
503
566
|
return wsModel.subscribe(update)
|
|
504
567
|
}, [wsModel])
|
|
505
568
|
const editing = Boolean(job && job.id)
|
|
506
|
-
|
|
569
|
+
// 构建器初始状态:新建任务把设备时区偏移固化为显式后缀,
|
|
570
|
+
// 宿主环境时区异常(如 UTC)时任务仍按设备预期时刻触发;编辑存量保持原表达式语义
|
|
571
|
+
const [scheduleState, setScheduleState] = useState(() => {
|
|
572
|
+
const parsed = parseSchedule(job ? job.schedule : '0 9 * * *')
|
|
573
|
+
if (!editing) parsed.timezone = deviceTzOffset()
|
|
574
|
+
return parsed
|
|
575
|
+
})
|
|
576
|
+
const initialSchedule = editing ? job.schedule : buildSchedule(scheduleState)
|
|
507
577
|
const [form, setForm] = useState(() => ({
|
|
508
578
|
name: job ? job.name : '',
|
|
509
579
|
kind: job ? job.kind : 'shell',
|
|
@@ -521,15 +591,24 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
521
591
|
agentPreset: job && job.session ? job.session.agentPreset : '',
|
|
522
592
|
},
|
|
523
593
|
}))
|
|
524
|
-
// 构建器状态:由既有表达式反解初始化;结构化变更写回 form.schedule
|
|
525
|
-
const [scheduleState, setScheduleState] = useState(() => parseSchedule(initialSchedule))
|
|
526
594
|
const [preview, setPreview] = useState(null)
|
|
595
|
+
const [previewError, setPreviewError] = useState(null)
|
|
527
596
|
const [error, setError] = useState(null)
|
|
528
597
|
const [saving, setSaving] = useState(false)
|
|
529
598
|
const [presetCatalog, setPresetCatalog] = useState({ defaultId: '', items: [] })
|
|
530
599
|
const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
|
|
531
600
|
const setSession = (patch) => setForm((prev) => ({ ...prev, session: { ...prev.session, ...patch } }))
|
|
532
601
|
const applyScheduleState = (next) => {
|
|
602
|
+
// custom 频率下表达式携带合法后缀时以表达式为准(剥离归一并回填时区);
|
|
603
|
+
// 无后缀保留调用方传入的时区(select 与频率切换不丢配置);非法后缀原文保留且时区置空
|
|
604
|
+
if (next.freq === 'custom') {
|
|
605
|
+
try {
|
|
606
|
+
const split = sbSplitScheduleTz(next.expression)
|
|
607
|
+
if (split.timezoneOffset != null) next = { ...next, expression: split.base, timezone: split.timezoneOffset }
|
|
608
|
+
} catch {
|
|
609
|
+
next = { ...next, timezone: null }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
533
612
|
setScheduleState(next)
|
|
534
613
|
set({ schedule: buildSchedule(next) })
|
|
535
614
|
}
|
|
@@ -545,9 +624,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
545
624
|
|
|
546
625
|
useEffect(() => {
|
|
547
626
|
let alive = true
|
|
627
|
+
// 表达式变更即清空旧错误:防抖窗口内不残留上一次失败文案
|
|
628
|
+
setPreviewError(null)
|
|
548
629
|
const timer = setTimeout(async () => {
|
|
549
630
|
const outcome = await request('POST', 'cron/preview', { schedule: form.schedule })
|
|
550
|
-
if (alive)
|
|
631
|
+
if (!alive) return
|
|
632
|
+
setPreview(outcome.ok ? outcome.data : null)
|
|
633
|
+
setPreviewError(outcome.ok ? null : outcome.error)
|
|
551
634
|
}, 300)
|
|
552
635
|
return () => { alive = false; clearTimeout(timer) }
|
|
553
636
|
}, [form.schedule])
|
|
@@ -575,7 +658,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
575
658
|
ariaLabel: '类型',
|
|
576
659
|
}))),
|
|
577
660
|
h(Section, { title: '调度计划' },
|
|
578
|
-
h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview })),
|
|
661
|
+
h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview, previewError, serverTzOffset })),
|
|
579
662
|
form.kind === 'shell'
|
|
580
663
|
? h(Section, { title: '运行配置(shell)' },
|
|
581
664
|
h(Field, { label: '命令', hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
|
|
@@ -739,7 +822,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
739
822
|
onConfirm: () => deleteJob(pendingDelete),
|
|
740
823
|
onCancel: () => setPendingDelete(null),
|
|
741
824
|
}) : null,
|
|
742
|
-
formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, onDone: () => { setFormJob(null); reload() } }) : null)
|
|
825
|
+
formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, serverTzOffset: status ? status.serverTzOffset : undefined, onDone: () => { setFormJob(null); reload() } }) : null)
|
|
743
826
|
}
|
|
744
827
|
|
|
745
828
|
// —— 环境变量 Tab ——
|
|
@@ -874,11 +957,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
874
957
|
}
|
|
875
958
|
|
|
876
959
|
// —— 日志 Tab ——
|
|
877
|
-
function LogsTab({ jobs, reload, reloadFlag }) {
|
|
960
|
+
function LogsTab({ jobs, reload, reloadFlag, serverTzOffset }) {
|
|
878
961
|
const [jobId, setJobId] = useState(jobs.length > 0 ? jobs[0].id : null)
|
|
879
962
|
const [runs, setRuns] = useState([])
|
|
880
963
|
const [logText, setLogText] = useState(null)
|
|
881
964
|
const [logRunId, setLogRunId] = useState(null)
|
|
965
|
+
// 展示时区随所选任务:显式后缀用其偏移,无后缀用宿主默认(调度与展示同源)
|
|
966
|
+
const selectedJob = jobs.find((job) => job.id === jobId)
|
|
967
|
+
const displayTzOffset = scheduleTzOffset(selectedJob ? selectedJob.schedule : null) ?? (typeof serverTzOffset === 'number' ? serverTzOffset : undefined)
|
|
882
968
|
|
|
883
969
|
useEffect(() => {
|
|
884
970
|
let alive = true
|
|
@@ -912,7 +998,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
912
998
|
h('span', { className: 'cb-dot', style: { '--cb-status': STATUS_TONE_COLOR[meta.tone] } }),
|
|
913
999
|
h('span', { className: 'cb-meta' }, meta.label),
|
|
914
1000
|
h('span', { className: 'cb-badge' }, TRIGGER_META[run.trigger] ? TRIGGER_META[run.trigger].label : run.trigger),
|
|
915
|
-
h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt)),
|
|
1001
|
+
h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt, displayTzOffset)),
|
|
916
1002
|
typeof run.durationMs === 'number' ? h('span', { className: 'cb-meta' }, formatDuration(run.durationMs)) : null,
|
|
917
1003
|
run.message ? h('span', { className: 'cb-meta' }, run.message) : null,
|
|
918
1004
|
h('div', { className: 'cb-actions' },
|
|
@@ -972,7 +1058,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
972
1058
|
(status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
|
|
973
1059
|
tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, applyJobPatch, wsModel }) : null,
|
|
974
1060
|
tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload, applyEnvPatch }) : null,
|
|
975
|
-
tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
|
|
1061
|
+
tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag, serverTzOffset: status ? status.serverTzOffset : undefined }) : null)
|
|
976
1062
|
}
|
|
977
1063
|
|
|
978
1064
|
// —— 主页面挂载:宿主官方全局面板契约(main keyed 插槽 + sidebar.panellist 侧栏入口)——
|
package/src/cron.mjs
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
// cron 解析薄封装:croner 承担表达式解析与触发点计算(5/6 段通吃、时区、DST)。
|
|
2
|
-
//
|
|
2
|
+
// 表达式支持内联时区后缀 T±N(语法归 schedule-builder):显式后缀按固定偏移,
|
|
3
|
+
// 无后缀按宿主系统时区(crontab 惯例,与 croner 原生行为一致)。
|
|
3
4
|
|
|
4
5
|
import { Cron } from 'croner'
|
|
5
6
|
|
|
7
|
+
import { splitScheduleTz, formatTzOffset } from './schedule-builder.mjs'
|
|
8
|
+
|
|
9
|
+
// 固定偏移 → croner 时区名(Etc/GMT 为 POSIX 反转:UTC+8 即 Etc/GMT-8)
|
|
10
|
+
function etcGmtName(timezoneOffset) {
|
|
11
|
+
if (timezoneOffset === 0) return 'UTC'
|
|
12
|
+
return timezoneOffset > 0 ? 'Etc/GMT-' + timezoneOffset : 'Etc/GMT+' + (-timezoneOffset)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// croner 构造选项:无后缀不指定时区(宿主系统时区)
|
|
16
|
+
function cronOptions(timezoneOffset) {
|
|
17
|
+
return timezoneOffset === null ? {} : { timezone: etcGmtName(timezoneOffset) }
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
// 求自 from 起下一次触发点(ms epoch);表达式无未来触发点时返回 null
|
|
7
21
|
export function nextRunAtOf(schedule, from = new Date()) {
|
|
8
|
-
const
|
|
22
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
23
|
+
const job = new Cron(base, cronOptions(timezoneOffset))
|
|
9
24
|
const next = job.nextRun(from)
|
|
10
25
|
return next ? next.getTime() : null
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
// 求自 from 起下 N 次触发点(编辑表单实时预览用)
|
|
14
29
|
export function nextRunsOf(schedule, count, from = new Date()) {
|
|
15
|
-
const
|
|
30
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
31
|
+
const job = new Cron(base, cronOptions(timezoneOffset))
|
|
16
32
|
return job.nextRuns(count, from).map((date) => date.getTime())
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
// 表达式合法性校验:合法返回 null,非法抛业务错误
|
|
35
|
+
// 表达式合法性校验:合法返回 null,非法抛业务错误(后缀与表达式分别报因)
|
|
20
36
|
export function assertValidSchedule(schedule) {
|
|
37
|
+
const { base } = splitScheduleTz(schedule)
|
|
21
38
|
try {
|
|
22
|
-
new Cron(
|
|
39
|
+
new Cron(base)
|
|
23
40
|
} catch (error) {
|
|
24
41
|
throw new Error('cron 表达式不合法: ' + schedule)
|
|
25
42
|
}
|
|
@@ -28,23 +45,32 @@ export function assertValidSchedule(schedule) {
|
|
|
28
45
|
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
|
29
46
|
const PAD2 = (value) => String(value).padStart(2, '0')
|
|
30
47
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
// 时区尾注:显式后缀标注自身,无后缀标注宿主默认(hostOffset 可显式传入以便测试)
|
|
49
|
+
function tzNote(timezoneOffset, hostOffset) {
|
|
50
|
+
return timezoneOffset === null
|
|
51
|
+
? '(宿主 UTC' + formatTzOffset(hostOffset) + ')'
|
|
52
|
+
: '(UTC' + formatTzOffset(timezoneOffset) + ')'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 人话摘要(轻量规则覆盖常用形态,兜底回原表达式),一律携带时区尾注
|
|
56
|
+
export function summarizeCron(schedule, hostOffset = -new Date().getTimezoneOffset() / 60) {
|
|
57
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
58
|
+
const note = tzNote(timezoneOffset, hostOffset)
|
|
59
|
+
const parts = base.trim().split(/\s+/)
|
|
60
|
+
if (parts.length !== 5) return base + note
|
|
35
61
|
const [minute, hour, day, month, weekday] = parts
|
|
36
62
|
const clock = (h, m) => PAD2(h) + ':' + PAD2(m)
|
|
37
63
|
const dayFree = day === '*' && month === '*' && weekday === '*'
|
|
38
|
-
if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时'
|
|
39
|
-
if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)'
|
|
40
|
-
if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟'
|
|
41
|
-
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return
|
|
64
|
+
if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时' + note
|
|
65
|
+
if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)' + note
|
|
66
|
+
if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟' + note
|
|
67
|
+
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return base + note
|
|
42
68
|
if (month !== '*' || day !== '*') {
|
|
43
|
-
if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute)
|
|
44
|
-
return
|
|
69
|
+
if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute) + note
|
|
70
|
+
return base + note
|
|
45
71
|
}
|
|
46
|
-
if (weekday === '1-5') return '工作日 ' + clock(hour, minute)
|
|
47
|
-
if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute)
|
|
48
|
-
if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute)
|
|
49
|
-
return
|
|
72
|
+
if (weekday === '1-5') return '工作日 ' + clock(hour, minute) + note
|
|
73
|
+
if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute) + note
|
|
74
|
+
if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute) + note
|
|
75
|
+
return base + note
|
|
50
76
|
}
|
package/src/schedule-builder.mjs
CHANGED
|
@@ -10,7 +10,8 @@ export const MINUTE_MAX = 59
|
|
|
10
10
|
export const HOUR_MAX = 23
|
|
11
11
|
export const INTERVAL_MIN_MINUTES = 1
|
|
12
12
|
|
|
13
|
-
// 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置)
|
|
13
|
+
// 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置);
|
|
14
|
+
// timezone 为内联时区后缀的偏移小时数,null 表示无后缀(按宿主系统时区)
|
|
14
15
|
export function createScheduleState(expression) {
|
|
15
16
|
return {
|
|
16
17
|
freq: 'daily',
|
|
@@ -19,6 +20,7 @@ export function createScheduleState(expression) {
|
|
|
19
20
|
weekdays: ['1'],
|
|
20
21
|
intervalMinutes: 30,
|
|
21
22
|
expression,
|
|
23
|
+
timezone: null,
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -28,11 +30,21 @@ const clampInt = (raw, min, max, fallback) => {
|
|
|
28
30
|
return value
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom
|
|
33
|
+
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
|
|
34
|
+
// 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
|
|
32
35
|
export function parseSchedule(expression) {
|
|
33
36
|
const raw = String(expression).trim()
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
let split
|
|
38
|
+
try {
|
|
39
|
+
split = splitScheduleTz(raw)
|
|
40
|
+
} catch {
|
|
41
|
+
const state = createScheduleState(raw)
|
|
42
|
+
state.freq = 'custom'
|
|
43
|
+
return state
|
|
44
|
+
}
|
|
45
|
+
const state = createScheduleState(split.base)
|
|
46
|
+
state.timezone = split.timezoneOffset
|
|
47
|
+
const parts = split.base.split(/\s+/)
|
|
36
48
|
if (parts.length !== 5) { state.freq = 'custom'; return state }
|
|
37
49
|
const [minute, hour, day, month, weekday] = parts
|
|
38
50
|
if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
|
|
@@ -60,8 +72,19 @@ export function parseSchedule(expression) {
|
|
|
60
72
|
return state
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
//
|
|
75
|
+
// 偏移小时数 → 后缀符号值:+8 / -5 / +0
|
|
76
|
+
export function formatTzOffset(offset) {
|
|
77
|
+
return (offset >= 0 ? '+' : '') + offset
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
|
|
64
81
|
export function buildSchedule(state) {
|
|
82
|
+
const base = buildBase(state)
|
|
83
|
+
if (state.timezone == null) return base
|
|
84
|
+
return base + 'T' + formatTzOffset(state.timezone)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildBase(state) {
|
|
65
88
|
if (state.freq === 'custom') return String(state.expression).trim()
|
|
66
89
|
const minute = String(state.minute)
|
|
67
90
|
const hour = String(state.hour)
|
|
@@ -71,3 +94,23 @@ export function buildSchedule(state) {
|
|
|
71
94
|
if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
|
|
72
95
|
return String(state.expression).trim()
|
|
73
96
|
}
|
|
97
|
+
|
|
98
|
+
// —— 内联时区后缀:表达式形态的一部分,构建器读写,cron.mjs 桥接 croner 时复用 ——
|
|
99
|
+
|
|
100
|
+
export const TZ_SUFFIX_MIN = -12
|
|
101
|
+
export const TZ_SUFFIX_MAX = 14
|
|
102
|
+
// T±N 整数小时,允许与表达式空格分隔,大小写均可
|
|
103
|
+
const TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
|
|
104
|
+
|
|
105
|
+
// 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
|
|
106
|
+
// 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
|
|
107
|
+
export function splitScheduleTz(schedule) {
|
|
108
|
+
const text = String(schedule).trim()
|
|
109
|
+
const match = text.match(TZ_SUFFIX_PATTERN)
|
|
110
|
+
if (!match) return { base: text, timezoneOffset: null }
|
|
111
|
+
const offset = Number(match[1] + match[2])
|
|
112
|
+
if (offset < TZ_SUFFIX_MIN || offset > TZ_SUFFIX_MAX) {
|
|
113
|
+
throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + TZ_SUFFIX_MIN + ' 至 ' + TZ_SUFFIX_MAX + ')')
|
|
114
|
+
}
|
|
115
|
+
return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// cron 解析封装测试:内联时区后缀 T±N 的触发点计算、校验与人话摘要(BDD feat-timezone S1)。
|
|
2
|
+
// 后缀语义:显式后缀按固定偏移;无后缀按宿主系统时区(与 croner 原生行为一致)。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { Cron } from 'croner'
|
|
7
|
+
|
|
8
|
+
import { nextRunAtOf, nextRunsOf, assertValidSchedule, summarizeCron } from '../src/cron.mjs'
|
|
9
|
+
import { buildSchedule } from '../src/schedule-builder.mjs'
|
|
10
|
+
|
|
11
|
+
// 固定起点:2026-09-17T12:00:00Z(北京 20:00),之后的 9:00 触发点跨日,断言无歧义
|
|
12
|
+
const FROM = new Date('2026-09-17T12:00:00Z')
|
|
13
|
+
|
|
14
|
+
test('cron:后缀 T+8 按东八区计算触发点', () => {
|
|
15
|
+
// Given 表达式 0 9 * * *T+8
|
|
16
|
+
// When 求自固定起点起的下一次触发
|
|
17
|
+
const at = nextRunAtOf('0 9 * * *T+8', FROM)
|
|
18
|
+
// Then 触发点为北京 09-18 09:00,即 01:00Z
|
|
19
|
+
assert.equal(at, Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('cron:后缀 T+0 与 T-5 按固定偏移计算,允许后缀前空格', () => {
|
|
23
|
+
// Then T+0 即 UTC 触发点 09:00Z
|
|
24
|
+
assert.equal(nextRunAtOf('0 9 * * *T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
25
|
+
// Then 空格分隔等价紧贴
|
|
26
|
+
assert.equal(nextRunAtOf('0 9 * * * T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
27
|
+
// Then T-5 即 UTC-5 的 9:00,起点(12:00Z)之后的下一次为当日 14:00Z
|
|
28
|
+
assert.equal(nextRunAtOf('0 9 * * *T-5', FROM), Date.UTC(2026, 8, 17, 14, 0, 0))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('cron:后缀 T+8 下三次触发连续推进', () => {
|
|
32
|
+
// Given 显式东八区,取 3 个触发点
|
|
33
|
+
const runs = nextRunsOf('0 9 * * *T+8', 3, FROM)
|
|
34
|
+
// Then 为连续三天北京 09:00(01:00Z)
|
|
35
|
+
assert.deepEqual(runs, [
|
|
36
|
+
Date.UTC(2026, 8, 18, 1, 0, 0),
|
|
37
|
+
Date.UTC(2026, 8, 19, 1, 0, 0),
|
|
38
|
+
Date.UTC(2026, 8, 20, 1, 0, 0),
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('cron:无后缀与 croner 原生系统时区行为一致', () => {
|
|
43
|
+
// Given 无后缀表达式
|
|
44
|
+
// When 求下一次触发
|
|
45
|
+
const at = nextRunAtOf('30 8 * * *', FROM)
|
|
46
|
+
// Then 与 croner 原生(不传 timezone)一致,零回归
|
|
47
|
+
const native = new Cron('30 8 * * *').nextRun(FROM).getTime()
|
|
48
|
+
assert.equal(at, native)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('cron:后缀解析边界——负零、前导零、小写、空格归一', () => {
|
|
52
|
+
// Then 负零等价 +0(UTC)
|
|
53
|
+
assert.equal(nextRunAtOf('0 9 * * *T-0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
54
|
+
// Then 前导零等价单位数
|
|
55
|
+
assert.equal(nextRunAtOf('0 9 * * *T+08', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
56
|
+
// Then 小写 t 同样识别
|
|
57
|
+
assert.equal(nextRunAtOf('0 9 * * *t+8', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
58
|
+
// Then 空格分隔反解后回写归一为紧贴形态
|
|
59
|
+
const state = { freq: 'daily', hour: 9, minute: 0, weekdays: ['1'], intervalMinutes: 30, expression: '0 9 * * *', timezone: 8 }
|
|
60
|
+
assert.equal(buildSchedule(state), '0 9 * * *T+8')
|
|
61
|
+
// Then 负零写侧归一为 +0(不输出 T-0)
|
|
62
|
+
assert.equal(buildSchedule({ ...state, timezone: -0 }), '0 9 * * *T+0')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('cron:nextRunAtOf 对非法后缀直接抛业务错误', () => {
|
|
66
|
+
assert.throws(() => nextRunAtOf('0 9 * * *T+15', FROM), /时区后缀不合法/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('cron:非法后缀抛业务错误,坏表达式抛表达式错误', () => {
|
|
70
|
+
// Then 后缀超界(-12..14 之外)报后缀不合法
|
|
71
|
+
assert.throws(() => assertValidSchedule('0 9 * * *T+15'), /时区后缀不合法: T\+15/)
|
|
72
|
+
assert.throws(() => assertValidSchedule('0 9 * * *T-13'), /时区后缀不合法/)
|
|
73
|
+
// Then 表达式本身坏报表达式不合法(文案含原文)
|
|
74
|
+
assert.throws(() => assertValidSchedule('not-a-cronT+8'), /cron 表达式不合法: not-a-cronT\+8/)
|
|
75
|
+
// Then 合法表达式不抛
|
|
76
|
+
assert.doesNotThrow(() => assertValidSchedule('0 9 * * *T+8'))
|
|
77
|
+
assert.doesNotThrow(() => assertValidSchedule('0 9 * * *'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('cron:摘要显式后缀尾注 (UTC+N)', () => {
|
|
81
|
+
assert.equal(summarizeCron('0 9 * * *T+8'), '每天 09:00(UTC+8)')
|
|
82
|
+
assert.equal(summarizeCron('30 8 * * 1-5 T+0'), '工作日 08:30(UTC+0)')
|
|
83
|
+
assert.equal(summarizeCron('0 22 * * 6T-5'), '每周六 22:00(UTC-5)')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('cron:摘要无后缀尾注显示宿主偏移,显式传参可测', () => {
|
|
87
|
+
// Given 宿主偏移显式传 0
|
|
88
|
+
assert.equal(summarizeCron('0 9 * * *', 0), '每天 09:00(宿主 UTC+0)')
|
|
89
|
+
// Given 宿主偏移显式传 5.5(半时区宿主)
|
|
90
|
+
assert.equal(summarizeCron('0 9 * * *', 5.5), '每天 09:00(宿主 UTC+5.5)')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('cron:摘要 custom 与非 5 段兜底同样携带时区尾注', () => {
|
|
94
|
+
// Given 指定日月(非构建器形态)带后缀
|
|
95
|
+
assert.equal(summarizeCron('0 0 1 1 *T+8'), '0 0 1 1 *(UTC+8)')
|
|
96
|
+
// Given 非法表达式(不构成摘要)带宿主偏移传参
|
|
97
|
+
assert.equal(summarizeCron('not-a-cron', 0), 'not-a-cron(宿主 UTC+0)')
|
|
98
|
+
})
|
package/test/executor.test.mjs
CHANGED
|
@@ -36,14 +36,21 @@ async function makeExecutor(t, { jobsBroken = false, logLines = [] } = {}) {
|
|
|
36
36
|
|
|
37
37
|
const JOB_BASE = { name: 't', kind: 'shell', command: 'echo hi', schedule: '* * * * *', enabled: true, timeoutMs: 60 * 1000 }
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (
|
|
43
|
-
|
|
44
|
-
if (
|
|
39
|
+
// 轮询至断言目标状态可见:执行链在 runs 终态后仍有任务卡回填/留痕等后台落定动作,单次读取存在竞态窗口
|
|
40
|
+
async function waitFor(predicate, timeoutMs = 2 * 1000) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (predicate()) return
|
|
44
|
+
if (Date.now() > deadline) throw new Error('waitFor 超时')
|
|
45
45
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
46
46
|
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function dispatchAndSettle(executor, store, jobId) {
|
|
50
|
+
const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
|
|
51
|
+
// 等执行链完整落定:runs 终态内存生效早于写链持久化,任务卡回填在其后才可见,以回填为收尾信号
|
|
52
|
+
// 信号为任务级(同名任务任一单元回填即满足),非特定 run 级;当前调用点无依赖特定 run 落定的断言
|
|
53
|
+
await waitFor(() => store.jobs.get(jobId)?.lastStatus !== undefined)
|
|
47
54
|
return runId
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -87,12 +94,8 @@ test('executor:pinned 自愈回写 session.pinnedSessionId 且清除顶层孤立
|
|
|
87
94
|
readMaxConcurrent: () => 2,
|
|
88
95
|
})
|
|
89
96
|
const [runId] = await executorWithPinned.dispatch({ ...JOB_BASE, kind: 'session', session: { mode: 'pinned', pinnedSessionId: '' }, id: 'j-pin' }, 'manual')
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (row && row.status !== 'queued' && row.status !== 'running') break
|
|
93
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
94
|
-
}
|
|
95
|
-
// Then 绑定写入 session 子对象,顶层无孤立残留
|
|
97
|
+
// Then 绑定写入 session 子对象,顶层无孤立残留;回填晚于 runs 终态,以回填可见为落定信号
|
|
98
|
+
await waitFor(() => store.jobs.get('j-pin')?.session?.pinnedSessionId === 's-new')
|
|
96
99
|
const job = store.jobs.get('j-pin')
|
|
97
100
|
assert.equal(job.session.pinnedSessionId, 's-new')
|
|
98
101
|
assert.equal(job.pinnedSessionId, undefined)
|
|
@@ -114,11 +117,8 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
|
|
|
114
117
|
})
|
|
115
118
|
await store.jobs.create({ ...JOB_BASE, id: 'j2' })
|
|
116
119
|
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j2' }, 'manual')
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (row && row.status === 'fail') break
|
|
120
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
121
|
-
}
|
|
120
|
+
// catch 路径先补 runs 终态再回填任务卡,以回填可见为收尾信号
|
|
121
|
+
await waitFor(() => store.jobs.get('j2')?.lastStatus === 'fail')
|
|
122
122
|
const row = store.runs.get(runId)
|
|
123
123
|
assert.equal(row.status, 'fail')
|
|
124
124
|
assert.match(row.message, /会话服务爆炸/)
|
|
@@ -129,7 +129,9 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
|
|
|
129
129
|
test('executor:运行终态已落库而任务卡回填失败,历史不被覆写', async (t) => {
|
|
130
130
|
const { store, executor, logLines } = await makeExecutor(t, { jobsBroken: true })
|
|
131
131
|
await store.jobs.create({ ...JOB_BASE, id: 'j3' })
|
|
132
|
-
const runId = await
|
|
132
|
+
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j3' }, 'manual')
|
|
133
|
+
// 任务卡回填被拒必留痕:留痕集合即 catch 路径收尾信号(此场景 lastStatus 永不可见)
|
|
134
|
+
await waitFor(() => logLines.length === 1)
|
|
133
135
|
// Then 事实成功保留,不被 catch 覆写为 fail;回填缺口留痕可观测
|
|
134
136
|
assert.equal(store.runs.get(runId).status, 'success')
|
|
135
137
|
assert.equal(logLines.length, 1)
|
|
@@ -143,7 +145,8 @@ test('executor:运行终态补写失败经 logSystem 留痕', async (t) => {
|
|
|
143
145
|
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j4' }, 'manual')
|
|
144
146
|
// dispatch 完成(记录已落库、runUnit 已入队)后再让 runs 集合损坏:终态写入必失败
|
|
145
147
|
store.runs.update = async () => { throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失') }
|
|
146
|
-
|
|
148
|
+
// 补写失败必留痕:以留痕可见替代固定等待,消除时序脆弱
|
|
149
|
+
await waitFor(() => logLines.length === 1)
|
|
147
150
|
// Then 补写失败恰留痕一次,含 runId 与原始错误
|
|
148
151
|
assert.equal(logLines.length, 1)
|
|
149
152
|
assert.ok(logLines[0].includes(runId))
|
package/test/index.test.mjs
CHANGED
|
@@ -203,7 +203,8 @@ test('index:tick 到期任务被调度执行(端到端装配)', async (t) => {
|
|
|
203
203
|
const dir = await mkdtemp(join(tmpdir(), 'cron-board-idx-'))
|
|
204
204
|
t.after(async () => {
|
|
205
205
|
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
206
|
-
|
|
206
|
+
// 运行终态可见后执行链仍有后台落盘(任务卡回填/日志写),与目录删除竞态,有界重试消解
|
|
207
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
207
208
|
})
|
|
208
209
|
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
209
210
|
const { ctx, routes, intervals } = makeFullCtx()
|
package/test/routes.test.mjs
CHANGED
|
@@ -465,9 +465,9 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
|
|
|
465
465
|
const { api } = await makeApi(t)
|
|
466
466
|
// When 预览「每天 08:30」
|
|
467
467
|
const ok = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *' })
|
|
468
|
-
// Then
|
|
468
|
+
// Then 摘要含每天与时刻并带宿主时区尾注,三次触发时间戳齐备
|
|
469
469
|
assert.equal(ok.status, 200)
|
|
470
|
-
assert.
|
|
470
|
+
assert.match(ok.payload.summary, /^每天 08:30\(宿主 UTC[+-]\d/)
|
|
471
471
|
assert.equal(ok.payload.nextAt.length, 3)
|
|
472
472
|
assert.ok(ok.payload.nextAt.every((value) => typeof value === 'number' && value > Date.now()))
|
|
473
473
|
// When 非法表达式
|
|
@@ -477,6 +477,33 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
|
|
|
477
477
|
assert.match(bad.payload.error, /cron/i)
|
|
478
478
|
})
|
|
479
479
|
|
|
480
|
+
test('jobs 与 preview 路由:时区后缀表达式的调度与校验', async (t) => {
|
|
481
|
+
const { api } = await makeApi(t)
|
|
482
|
+
// Given 带 T+8 后缀的任务
|
|
483
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz', kind: 'shell', command: 'x', schedule: '0 9 * * *T+8' })
|
|
484
|
+
assert.equal(created.status, 200)
|
|
485
|
+
// Then 默认触发点按东八区 9:00(01:00Z),与宿主自身时区无关
|
|
486
|
+
assert.equal(new Date(created.payload.nextRunAt).toISOString().slice(11, 16), '01:00')
|
|
487
|
+
// When 非法后缀创建任务
|
|
488
|
+
const bad = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz2', kind: 'shell', command: 'x', schedule: '0 9 * * *T+15' })
|
|
489
|
+
// Then 400 且报后缀不合法
|
|
490
|
+
assert.equal(bad.status, 400)
|
|
491
|
+
assert.match(bad.payload.error, /时区后缀不合法/)
|
|
492
|
+
// When 带后缀预览
|
|
493
|
+
const preview = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *T+8' })
|
|
494
|
+
// Then 摘要尾注与触发点均为东八区语义
|
|
495
|
+
assert.equal(preview.status, 200)
|
|
496
|
+
assert.equal(preview.payload.summary, '每天 08:30(UTC+8)')
|
|
497
|
+
assert.equal(new Date(preview.payload.nextAt[0]).toISOString().slice(11, 16), '00:30')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('status 路由:暴露宿主时区偏移 serverTzOffset', async (t) => {
|
|
501
|
+
const { api } = await makeApi(t)
|
|
502
|
+
const res = await call(api, 'GET', '/api/cron-board/status')
|
|
503
|
+
assert.equal(res.status, 200)
|
|
504
|
+
assert.equal(typeof res.payload.serverTzOffset, 'number')
|
|
505
|
+
})
|
|
506
|
+
|
|
480
507
|
test('runs 路由:无参数查全量,带 jobId 查单任务', async (t) => {
|
|
481
508
|
// Given 两个任务各有一条运行记录
|
|
482
509
|
const { store, api } = await makeApi(t)
|
|
@@ -102,6 +102,54 @@ test('字段越界数值反解归 custom,不猜修正', () => {
|
|
|
102
102
|
assert.equal(parseSchedule('* 9 * * *').freq, 'custom')
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test('时区后缀:反解与回写 T+N', () => {
|
|
106
|
+
const state = parseSchedule('0 9 * * *T+8')
|
|
107
|
+
assert.equal(state.freq, 'daily')
|
|
108
|
+
assert.equal(state.hour, 9)
|
|
109
|
+
assert.equal(state.timezone, 8)
|
|
110
|
+
assert.equal(buildSchedule(state), '0 9 * * *T+8')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('时区后缀:无后缀反解 timezone 为 null,回写不附加', () => {
|
|
114
|
+
const state = parseSchedule('30 8 * * *')
|
|
115
|
+
assert.equal(state.timezone, null)
|
|
116
|
+
assert.equal(buildSchedule(state), '30 8 * * *')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('时区后缀:interval 与工作日形态同样携带', () => {
|
|
120
|
+
const interval = parseSchedule('*/30 * * * *T+0')
|
|
121
|
+
assert.equal(interval.freq, 'interval')
|
|
122
|
+
assert.equal(interval.timezone, 0)
|
|
123
|
+
assert.equal(buildSchedule(interval), '*/30 * * * *T+0')
|
|
124
|
+
const weekdays = parseSchedule('0 9 * * 1-5T-5')
|
|
125
|
+
assert.equal(weekdays.freq, 'weekdays')
|
|
126
|
+
assert.equal(weekdays.timezone, -5)
|
|
127
|
+
assert.equal(buildSchedule(weekdays), '0 9 * * 1-5T-5')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('时区后缀:非法后缀反解 custom 且原文保留,不抛', () => {
|
|
131
|
+
const state = parseSchedule('0 9 * * *T+15')
|
|
132
|
+
assert.equal(state.freq, 'custom')
|
|
133
|
+
assert.equal(state.expression, '0 9 * * *T+15')
|
|
134
|
+
assert.equal(state.timezone, null)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('时区后缀:custom 合法后缀剥离存储,回写统一附加', () => {
|
|
138
|
+
const state = parseSchedule('0 9 * * * 2026T+8')
|
|
139
|
+
assert.equal(state.freq, 'custom')
|
|
140
|
+
assert.equal(state.expression, '0 9 * * * 2026')
|
|
141
|
+
assert.equal(state.timezone, 8)
|
|
142
|
+
assert.equal(buildSchedule(state), '0 9 * * * 2026T+8')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('时区后缀:createScheduleState 默认无后缀,置空回写不带后缀', () => {
|
|
146
|
+
const state = createScheduleState('')
|
|
147
|
+
assert.equal(state.timezone, null)
|
|
148
|
+
state.freq = 'daily'
|
|
149
|
+
state.hour = 2
|
|
150
|
+
assert.equal(buildSchedule(state), '0 2 * * *')
|
|
151
|
+
})
|
|
152
|
+
|
|
105
153
|
test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
|
|
106
154
|
// Given client 半区无模块系统,构建器逻辑必须内联(经典 script)
|
|
107
155
|
// When 从两侧源码提取函数体文本(SBUILD 标记段 vs schedule-builder.mjs 导出体)
|
|
@@ -115,7 +163,7 @@ test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
|
|
|
115
163
|
const core = readFileSync(join(pkgRoot, 'src', 'schedule-builder.mjs'), 'utf8')
|
|
116
164
|
// 逐函数对比:剥离 export 关键词后,核心源码的每个可执行行都应出现在 client 镜像段
|
|
117
165
|
const coreLines = core.split('\n')
|
|
118
|
-
.map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bclampInt\b/g, 'sbClampInt').trim())
|
|
166
|
+
.map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bTZ_SUFFIX_MIN\b/g, 'SB_TZ_SUFFIX_MIN').replace(/\bTZ_SUFFIX_MAX\b/g, 'SB_TZ_SUFFIX_MAX').replace(/\bTZ_SUFFIX_PATTERN\b/g, 'SB_TZ_SUFFIX_PATTERN').replace(/\bsplitScheduleTz\b/g, 'sbSplitScheduleTz').replace(/\bformatTzOffset\b/g, 'sbFormatTzOffset').replace(/\bbuildBase\b/g, 'sbBuildBase').replace(/\bclampInt\b/g, 'sbClampInt').trim())
|
|
119
167
|
.filter((line) => line !== '' && !line.startsWith('//'))
|
|
120
168
|
for (const line of coreLines) {
|
|
121
169
|
assert.ok(block.includes(line), 'client 镜像段缺少核心行: ' + line)
|