@mzzsfy/dsh-cron-board 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
3
  "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/api.mjs CHANGED
@@ -367,6 +367,9 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
367
367
  const existing = store.jobs.get(params.id)
368
368
  if (!existing) throw new Error(MESSAGES.jobNotFound)
369
369
  const fields = normalizeJob({ ...existing, ...body })
370
+ // 编辑保存:请求未显式携带 nextRunAt 即按当前 schedule 重算。
371
+ // 合并旧任务行会把既有 nextRunAt 当作显式值透传,改期后残留旧触发点导致误执行
372
+ if (body.nextRunAt === undefined) fields.nextRunAt = nextRunAtOf(fields.schedule)
370
373
  const row = await store.jobs.update(params.id, fields)
371
374
  sendJson(res, 200, row)
372
375
  },
package/src/client.js CHANGED
@@ -679,7 +679,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
679
679
  h('button', { className: 'cb-button cb-button--danger', onClick: onConfirm }, '删除')))
680
680
  }
681
681
 
682
- function JobsTab({ jobs, status, now, reload, wsModel }) {
682
+ function JobsTab({ jobs, status, now, reload, applyJobPatch, wsModel }) {
683
683
  const [formJob, setFormJob] = useState(null)
684
684
  const [notice, setNotice] = useState(null)
685
685
  const [kindFilter, setKindFilter] = useState('all')
@@ -691,7 +691,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
691
691
  setTimeout(reload, 300)
692
692
  }
693
693
  const toggleJob = async (job) => {
694
- await request('PATCH', 'jobs/' + job.id, { enabled: !job.enabled })
694
+ // 乐观翻转:受控开关即时到位;失败回滚并提示,不静默
695
+ applyJobPatch(job.id, { enabled: !job.enabled })
696
+ const outcome = await request('PATCH', 'jobs/' + job.id, { enabled: !job.enabled })
697
+ if (!outcome.ok) {
698
+ applyJobPatch(job.id, { enabled: job.enabled })
699
+ setNotice('切换失败:' + outcome.error)
700
+ }
695
701
  reload()
696
702
  }
697
703
  const deleteJob = async (job) => {
@@ -811,14 +817,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
811
817
  h('button', { className: 'cb-button cb-button--danger', onClick: () => doApply(true) }, '覆盖导入')))
812
818
  }
813
819
 
814
- function EnvsTab({ envs, reload }) {
820
+ function EnvsTab({ envs, reload, applyEnvPatch }) {
815
821
  const [formRow, setFormRow] = useState(null)
816
822
  const [importing, setImporting] = useState(false)
817
823
  const [exportText, setExportText] = useState(null)
818
824
  const [notice, setNotice] = useState(null)
819
825
 
820
826
  const toggleEnv = async (row) => {
821
- await request('PATCH', 'envs/' + row.id, { enabled: !row.enabled })
827
+ // 乐观翻转:受控开关即时到位;失败回滚并提示,不静默
828
+ applyEnvPatch(row.id, { enabled: !row.enabled })
829
+ const outcome = await request('PATCH', 'envs/' + row.id, { enabled: !row.enabled })
830
+ if (!outcome.ok) {
831
+ applyEnvPatch(row.id, { enabled: row.enabled })
832
+ setNotice('切换失败:' + outcome.error)
833
+ }
822
834
  reload()
823
835
  }
824
836
  const deleteEnv = async (row) => {
@@ -921,6 +933,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
921
933
  const [now, setNow] = useState(Date.now())
922
934
  const [reloadFlag, setReloadFlag] = useState(0)
923
935
  const reload = useCallback(() => setReloadFlag((value) => value + 1), [])
936
+ // 乐观更新:受控开关点击即时到位,PATCH 失败由调用方回滚;reload 拉权威态对账
937
+ const applyJobPatch = useCallback((id, patch) => {
938
+ setJobs((prev) => prev.map((job) => (job.id === id ? { ...job, ...patch } : job)))
939
+ }, [])
940
+ const applyEnvPatch = useCallback((id, patch) => {
941
+ setEnvs((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
942
+ }, [])
924
943
 
925
944
  useEffect(() => {
926
945
  if (!active) return undefined
@@ -949,8 +968,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
949
968
  status ? h('span', { className: 'cb-meta' },
950
969
  '运行中 ' + status.scheduler.active + ' · 队列 ' + status.scheduler.queued +
951
970
  (status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
952
- tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, wsModel }) : null,
953
- tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload }) : null,
971
+ tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, applyJobPatch, wsModel }) : null,
972
+ tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload, applyEnvPatch }) : null,
954
973
  tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
955
974
  }
956
975
 
package/src/scheduler.mjs CHANGED
@@ -11,12 +11,23 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
11
11
  // 重入闸门:上一 tick 未完成(store 写链排队/慢盘)时跳过本轮,防同一触发点双触发
12
12
  let ticking = false
13
13
 
14
- // 重启孤儿收尾:上次进程遗留的 queued/running 统一标 interrupted,不误标失败
14
+ // 历史版本编辑残留的自愈:nextRunAt 不在当前 schedule 的触发点上即重算,
15
+ // 防残留值到点误执行;合法触发点与空值(无未来触发点)不动,保留宽限执行与 misfire 语义
16
+ function isLegalTrigger(schedule, at) {
17
+ return nextRunAtOf(schedule, new Date(at - 1000)) === at
18
+ }
19
+
20
+ // 重启恢复:孤儿运行收尾 + 触发点残留自愈
15
21
  async function recover() {
16
22
  for (const run of [...store.runs.rows]) {
17
23
  if (run.status !== 'queued' && run.status !== 'running') continue
18
24
  await store.runs.update(run.runId, { status: 'interrupted', endedAt: now(), message: INTERRUPTED_MESSAGE })
19
25
  }
26
+ for (const job of store.jobs.list()) {
27
+ if (typeof job.nextRunAt !== 'number' || Number.isNaN(job.nextRunAt)) continue
28
+ if (isLegalTrigger(job.schedule, job.nextRunAt)) continue
29
+ await store.jobs.update(job.id, { nextRunAt: nextRunAtOf(job.schedule, new Date(now())) })
30
+ }
20
31
  }
21
32
 
22
33
  async function tick() {
@@ -12,6 +12,7 @@ import { createStore } from '../src/store.mjs'
12
12
  import { createLogger } from '../src/logger.mjs'
13
13
  import { createExecutor } from '../src/executor.mjs'
14
14
  import { createApi } from '../src/api.mjs'
15
+ import { nextRunAtOf } from '../src/cron.mjs'
15
16
 
16
17
  async function makeApi(t, overrides = {}) {
17
18
  const dir = await mkdtemp(join(tmpdir(), 'cron-board-api-'))
@@ -192,6 +193,38 @@ test('jobs 路由:非法 cron 表达式被拒', async (t) => {
192
193
  assert.match(res.payload.error, /cron/)
193
194
  })
194
195
 
196
+ test('jobs 路由:编辑保存按当前 schedule 重算 nextRunAt', async (t) => {
197
+ const { api } = await makeApi(t)
198
+ // Given 任务按工作日 09:55 排期
199
+ const created = await call(api, 'POST', '/api/cron-board/jobs', {
200
+ name: '改期', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
201
+ })
202
+ assert.equal(created.status, 200)
203
+ const jobId = created.payload.id
204
+ const before = created.payload.nextRunAt
205
+ // When 编辑改为每天 09:00(表单契约:请求体不带 nextRunAt)
206
+ const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + jobId, { schedule: '0 9 * * *', prompt: 'x' })
207
+ // Then 触发点随新表达式重算,不残留工作日旧值
208
+ assert.equal(patched.status, 200)
209
+ assert.notEqual(patched.payload.nextRunAt, before)
210
+ assert.equal(patched.payload.nextRunAt, nextRunAtOf('0 9 * * *'))
211
+ })
212
+
213
+ test('jobs 路由:PATCH 显式 nextRunAt 优先于重算(导入保留原状态契约)', async (t) => {
214
+ const { api } = await makeApi(t)
215
+ const created = await call(api, 'POST', '/api/cron-board/jobs', {
216
+ name: '导入', kind: 'shell', command: 'x', schedule: '* * * * *',
217
+ })
218
+ assert.equal(created.status, 200)
219
+ // When 请求体显式携带 nextRunAt
220
+ const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + created.payload.id, {
221
+ schedule: '0 9 * * *', nextRunAt: 1750000000000,
222
+ })
223
+ // Then 显式值落库,不被重算覆盖
224
+ assert.equal(patched.status, 200)
225
+ assert.equal(patched.payload.nextRunAt, 1750000000000)
226
+ })
227
+
195
228
  test('jobs 路由:手动运行端到端——RunRecord 产生、日志可读、状态终态', async (t) => {
196
229
  // Given shell 任务(node -e 输出标记)
197
230
  const { store, api } = await makeApi(t)
@@ -11,6 +11,7 @@ import { createStore } from '../src/store.mjs'
11
11
  import { createLogger } from '../src/logger.mjs'
12
12
  import { createExecutor } from '../src/executor.mjs'
13
13
  import { createScheduler } from '../src/scheduler.mjs'
14
+ import { nextRunAtOf } from '../src/cron.mjs'
14
15
 
15
16
  const TICK_MS = 30 * 1000
16
17
 
@@ -144,6 +145,52 @@ test('scheduler:recover 将遗留 queued/running 孤儿记录标 interrupted', a
144
145
  assert.equal(store.runs.get('r-done').status, 'success')
145
146
  })
146
147
 
148
+ test('scheduler:recover 自愈残留触发点——nextRunAt 非当前表达式触发点即重算', async (t) => {
149
+ // Given 旧版本编辑残留:工作日任务带着非工作日表达式的触发点(历史 bug 数据)
150
+ const { store, scheduler } = await makeScheduler(t)
151
+ const legal = nextRunAtOf('55 9 * * 1-5')
152
+ const stale = nextRunAtOf('0 9 * * *')
153
+ const jobStale = await store.jobs.create({
154
+ name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
155
+ enabled: true, timeoutMs: 1000, nextRunAt: stale,
156
+ })
157
+ const jobStaleDisabled = await store.jobs.create({
158
+ name: '残留禁用', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
159
+ enabled: false, timeoutMs: 1000, nextRunAt: stale,
160
+ })
161
+ const jobLegal = await store.jobs.create({
162
+ name: '合法', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
163
+ enabled: true, timeoutMs: 1000, nextRunAt: legal,
164
+ })
165
+ const jobNull = await store.jobs.create({
166
+ name: '空值', kind: 'shell', command: 'x', schedule: '0 0 30 2 *',
167
+ enabled: true, timeoutMs: 1000, nextRunAt: null,
168
+ })
169
+ // When 重启恢复
170
+ await scheduler.recover()
171
+ // Then 残留值(启用与禁用)按当前 schedule 重算为合法触发点;合法值与空值不动
172
+ assert.equal(store.jobs.get(jobStale.id).nextRunAt, legal)
173
+ assert.equal(store.jobs.get(jobStaleDisabled.id).nextRunAt, legal)
174
+ assert.equal(store.jobs.get(jobLegal.id).nextRunAt, legal)
175
+ assert.equal(store.jobs.get(jobNull.id).nextRunAt, null)
176
+ })
177
+
178
+ test('scheduler:recover 自愈幂等——修复后的数据再次恢复不再改写', async (t) => {
179
+ const { store, scheduler } = await makeScheduler(t)
180
+ const legal = nextRunAtOf('55 9 * * 1-5')
181
+ const job = await store.jobs.create({
182
+ name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
183
+ enabled: true, timeoutMs: 1000, nextRunAt: nextRunAtOf('0 9 * * *'),
184
+ })
185
+ await scheduler.recover()
186
+ assert.equal(store.jobs.get(job.id).nextRunAt, legal)
187
+ // When 时钟推进后再次恢复
188
+ await new Promise((resolve) => setTimeout(resolve, 1100))
189
+ await scheduler.recover()
190
+ // Then 已合法的触发点不被重算推迟
191
+ assert.equal(store.jobs.get(job.id).nextRunAt, legal)
192
+ })
193
+
147
194
  test('scheduler:并发闸门——maxConcurrent=1 时第二个单元排队,首个完成后推进', async (t) => {
148
195
  // Given 两个任务同时到期,全局并发 1
149
196
  const { store, runner, scheduler, advance } = await makeScheduler(t, { maxConcurrent: 1 })
@@ -60,6 +60,24 @@ test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
60
60
  assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
61
61
  })
62
62
 
63
+ test('启停开关乐观更新契约:即时翻转 + 失败回滚提示,禁止等待往返才反馈', () => {
64
+ // Given 受控 switch(checked 锁定 props)点击后无本地 state 变化不产生视觉反馈
65
+ // When toggleJob/toggleEnv 发起 PATCH
66
+ // Then 先乐观翻转,失败回滚并 notice,最后 reload 对账权威态(顺序锁定)
67
+ for (const fn of ['toggleJob', 'toggleEnv']) {
68
+ const start = source.indexOf('const ' + fn + ' = async')
69
+ assert.ok(start > 0, `缺少 ${fn}`)
70
+ const body = source.slice(start, source.indexOf('reload()', start))
71
+ assert.match(body, /apply(Job|Env)Patch\([^)]*, \{ enabled: !/m, `${fn} 必须先乐观翻转`)
72
+ assert.match(body, /if \(!outcome\.ok\)/m, `${fn} 必须检查 PATCH 结果`)
73
+ assert.match(body, /apply(Job|Env)Patch\([^)]*, \{ enabled: (job|row)\.enabled \}\)/m, `${fn} 失败必须回滚`)
74
+ assert.match(body, /切换失败:/m, `${fn} 失败必须提示,禁止静默`)
75
+ }
76
+ // 两个 Tab 均接收乐观更新通道(Board 下传)
77
+ assert.match(source, /applyJobPatch, wsModel/)
78
+ assert.match(source, /envs, reload, applyEnvPatch/)
79
+ })
80
+
63
81
  test('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
64
82
  // Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
65
83
  // 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)