@mzzsfy/dsh-cron-board 0.4.2 → 0.6.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 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
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:cron 看板渲染与编辑器、侧栏入口、激活 live。0.1.6 新侧栏标签页系统下联动开关双态均验证通过;0.1.7-rc.1 修复 ui-settings 写失败打崩宿主(await 化 + 路由级 catch 降级),内置插件页改版后设置开关卡锚缺失(宿主演进),看板面实测正常。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
3
  "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.4.2",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
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
  },
@@ -295,8 +297,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
295
297
  if (typeof body.sidebarTab === 'boolean') patch.sidebarTab = body.sidebarTab
296
298
  if (!Object.keys(patch).length) return sendJson(res, 400, { error: '无有效字段' })
297
299
  if (typeof updateUiSettings !== 'function') return sendJson(res, 200, { ok: false, error: '设置服务不可用' })
298
- updateUiSettings(patch)
299
- sendJson(res, 200, { ok: true, ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false } })
300
+ const persisted = await updateUiSettings(patch)
301
+ sendJson(res, 200, { ok: persisted, ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false } })
300
302
  },
301
303
  },
302
304
  {
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
- const date = new Date(timestamp)
40
- return date.getFullYear() + '-' + PAD2(date.getMonth() + 1) + '-' + PAD2(date.getDate()) + ' ' + PAD2(date.getHours()) + ':' + PAD2(date.getMinutes())
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
- const parts = raw.split(/\s+/)
86
- const state = createScheduleState(raw)
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
- // 构建器状态 → cron;custom 直接返回手工表达式
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
- const initialSchedule = job ? job.schedule : '0 9 * * *'
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) setPreview(outcome.ok ? outcome.data : null)
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 侧栏入口)——
@@ -983,6 +1069,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
983
1069
  const TAB_ID = 'cron-board:board'
984
1070
  const PANEL_ID = 'cron-board'
985
1071
  const PANEL_LABEL = '定时任务'
1072
+ const PKG_ID = '@mzzsfy/dsh-cron-board'
986
1073
 
987
1074
  // 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
988
1075
  const CARD_TITLE = '定时任务'
@@ -1133,6 +1220,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1133
1220
  () => h(CronBoardPluginCard, { ctx }),
1134
1221
  )
1135
1222
  }), 'cron-board settings card')
1223
+ // 插件管理页(0.1.7-rc.1+)包详情配置卡:key = 包名,管理页 ledger.bundles 判定 section
1224
+ // 在场;卡片自包含(只调本插件 API,不触宿主 settings)。两代槽位并存注册:inject 是
1225
+ // 声明生命周期效应,错误世代宿主永不声明该槽,回调挂起不执行,零成本
1226
+ ctx.effect(() => ctx.slots.inject('plugins.bundle.config', function* () {
1227
+ yield ctx.slots.register(
1228
+ { name: 'plugins.bundle.config', key: PKG_ID },
1229
+ () => h(CronBoardPluginCard, { ctx }),
1230
+ )
1231
+ }), 'cron-board plugin manager card')
1136
1232
  ctx.inject(['betterSidebar'], (bsCtx) => {
1137
1233
  currentSidebar = bsCtx.betterSidebar
1138
1234
  applyPref()
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 job = new Cron(schedule)
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 job = new Cron(schedule)
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(schedule)
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
- export function summarizeCron(schedule) {
33
- const parts = String(schedule).trim().split(/\s+/)
34
- if (parts.length !== 5) return schedule
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 schedule
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 schedule
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 schedule
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/index.js CHANGED
@@ -43,6 +43,21 @@ const SETTINGS_SCHEMA = schemastery.object({
43
43
  sidebarTab: schemastery.boolean().default(false).description('看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面)'),
44
44
  })
45
45
 
46
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
47
+ // settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
48
+ // 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
49
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
50
+
51
+ function volatileWrap(schema) {
52
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
53
+ }
54
+
55
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
56
+ // legacy 宿主 config 为普通对象原样透传
57
+ function unwrapVolatile(value) {
58
+ return typeof value?.get === 'function' ? value.get() : value
59
+ }
60
+
46
61
  export function resolveDataDir(env = process.env) {
47
62
  const override = env[DATA_DIR_ENV]
48
63
  if (override && override.trim() !== '') return override.trim()
@@ -139,17 +154,42 @@ export function apply(ctx, config) {
139
154
  const value = readSettingValue(name)
140
155
  return typeof value === 'boolean' ? value : fallback
141
156
  }
142
- // 客户端设置面板写入通道:settings 服务在场时按命名空间合并补丁
143
- function updateUiSettings(patch) {
157
+ // 配置面双形态(maintain 同构,判定一律延迟到使用点,settings 挂载时序无保证):
158
+ // legacy(≤0.1.6)settings 服务 register/get/update 命名空间语义;
159
+ // 0.1.7+ 静态 Config 导出生成节表单,读经 volatile ref 解包,写经 configEditor
160
+ const legacySettingsFace = () => {
144
161
  const settings = ctx.get('settings')
145
- if (!settings || typeof settings.update !== 'function') return false
146
- settings.update(SETTINGS_NS, patch)
162
+ return typeof settings?.register === 'function' && typeof settings?.update === 'function'
163
+ }
164
+ // 0.1.7 写路径:configEditor.edit 定位本条目(fiber.entry,与命名空间解耦),
165
+ // change 回调合并落 profile patch;仅 volatile 字段变化时 loader 原地热更不重启
166
+ async function persistPatch(patch) {
167
+ if (legacySettingsFace()) {
168
+ await ctx.get('settings').update(SETTINGS_NS, patch)
169
+ return true
170
+ }
171
+ const editor = ctx.get('configEditor')
172
+ const entry = ctx.fiber?.entry
173
+ if (!editor || typeof editor.edit !== 'function' || !entry) return false
174
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
147
175
  return true
148
176
  }
177
+ // 客户端设置面板写入通道。必须 await:宿主写失败在异步段抛出(0.1.5 前
178
+ // settings 对未注册命名空间 "No configurable plugin entry" 实测打崩宿主),
179
+ // await 后异常传播到 api.handle 的路由级 catch,降级为 400 响应,宿主存活
180
+ async function updateUiSettings(patch) {
181
+ return persistPatch(patch)
182
+ }
149
183
  function readSettingValue(name) {
150
- const settings = ctx.get('settings')
151
- const scope = settings && settings.get ? settings.get(SETTINGS_NS) : undefined
152
- return scope ? scope[name] : undefined
184
+ try {
185
+ const settings = ctx.get('settings')
186
+ // 双形读:legacy settings 命名空间;0.1.7+ apply 入参 config 整节解包
187
+ const scope = settings && settings.get ? settings.get(SETTINGS_NS) : unwrapVolatile(config)
188
+ return scope ? scope[name] : undefined
189
+ } catch {
190
+ // 与写入同防:宿主 settings 对未注册命名空间抛错时读面降级为缺省
191
+ return undefined
192
+ }
153
193
  }
154
194
  function readTickMs() {
155
195
  return Math.max(MIN_TICK_MS, readNumber('tickSeconds', DEFAULT_TICK_MS / 1000) * 1000)
@@ -183,7 +223,14 @@ export function apply(ctx, config) {
183
223
  })
184
224
 
185
225
  ctx.inject(['settings'], (sctx) => {
186
- if (typeof sctx.settings.register !== 'function') return
226
+ // 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支
227
+ if (typeof sctx.settings.register !== 'function') {
228
+ // 0.1.7:原生自动设置页与本包插件卡重复,特性检测关闭(maintain 同构)
229
+ if (typeof sctx.settings?.configure === 'function') {
230
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
231
+ }
232
+ return
233
+ }
187
234
  const scope = sctx.settings.register(SETTINGS_NS, SETTINGS_SCHEMA, { base: config })
188
235
  // tick 周期等设置变更即时对账(重建 interval)
189
236
  if (scope && typeof scope.watch === 'function') {
@@ -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
- const parts = raw.split(/\s+/)
35
- const state = createScheduleState(raw)
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
- // 构建器状态 → cron;custom 直接返回手工表达式
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
+ })
@@ -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
- async function dispatchAndSettle(executor, store, jobId) {
40
- const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
41
- // 等执行链收尾:pump 后单元在后台,轮询至终态或超时
42
- for (let i = 0; i < 200; i++) {
43
- const row = store.runs.get(runId)
44
- if (row && row.status !== 'queued' && row.status !== 'running') return runId
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
- for (let i = 0; i < 200; i++) {
91
- const row = store.runs.get(runId)
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
- for (let i = 0; i < 200; i++) {
118
- const row = store.runs.get(runId)
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 dispatchAndSettle(executor, store, 'j3')
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
- await new Promise((resolve) => setTimeout(resolve, 200))
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))
@@ -185,6 +185,36 @@ test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期
185
185
  assert.deepEqual(intervals.map((entry) => entry.ms), [30 * 1000])
186
186
  })
187
187
 
188
+ test('index:settings.update 异步拒绝(镜像 rc.1 无命名空间条目)被路由级 catch 接住降级 400,不打崩进程', async () => {
189
+ // Given settings.update 返回 rejected promise 的宿主形态(0.1.7-rc.1 L3 实测:
190
+ // "No configurable plugin entry 'cron-board'" 在异步段抛出,不 await 时逃逸成
191
+ // uncaughtException 打崩宿主;await 后由 api.handle 路由级 catch 接住)
192
+ const { ctx, settingsService } = makeFullCtx()
193
+ settingsService.update = () => Promise.reject(new Error("No configurable plugin entry 'cron-board'"))
194
+ delete settingsService.get
195
+ const captured = []
196
+ const ctx2 = {
197
+ ...ctx,
198
+ get(name) {
199
+ if (name === 'settings') return settingsService
200
+ return ctx.get(name)
201
+ },
202
+ webServer: { register(route) { captured.push(route); return () => {} } },
203
+ }
204
+ apply(ctx2)
205
+ const route = captured[0]
206
+ assert.ok(route)
207
+ const res = { status: null, payload: null }
208
+ res.writeHead = (status) => { res.status = status }
209
+ res.end = (text) => { res.payload = JSON.parse(text) }
210
+ const req = { method: 'POST', url: 'http://localhost/api/cron-board/ui-settings', headers: { 'content-type': 'application/json' } }
211
+ req.on = (event, fn) => { if (event === 'data') setImmediate(() => fn(Buffer.from(JSON.stringify({ sidebarTab: true })))); if (event === 'end') setImmediate(fn) }
212
+ await route.handler(req, res)
213
+ // Then 路由级 catch 接住,降级 400 系统级错误响应,进程存活(测试正常走完即证)
214
+ assert.equal(res.status, 400)
215
+ assert.ok(res.payload.error)
216
+ })
217
+
188
218
  test('index:tickSeconds 设置变更经 watch 重建 interval', async () => {
189
219
  // Given 全服务桩,settings.register 的 watch 回调被捕获
190
220
  let watchFn = null
@@ -203,7 +233,8 @@ test('index:tick 到期任务被调度执行(端到端装配)', async (t) => {
203
233
  const dir = await mkdtemp(join(tmpdir(), 'cron-board-idx-'))
204
234
  t.after(async () => {
205
235
  delete process.env.DSH_CRON_BOARD_DATA_DIR
206
- await rm(dir, { recursive: true, force: true })
236
+ // 运行终态可见后执行链仍有后台落盘(任务卡回填/日志写),与目录删除竞态,有界重试消解
237
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
207
238
  })
208
239
  process.env.DSH_CRON_BOARD_DATA_DIR = dir
209
240
  const { ctx, routes, intervals } = makeFullCtx()
@@ -263,7 +294,188 @@ function waitForRecord(poll, timeoutMs = 10 * 1000) {
263
294
  }
264
295
  setTimeout(tick, 50)
265
296
  }).catch(reject)
297
+ }
298
+ tick()
299
+ })
300
+ }
301
+
302
+ // --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
303
+
304
+ // 0.1.7 形态装配桩:settings 无 register/get(宿主已移除),config 为整节单 ref 桩,
305
+ // configEditor 桩经 change 合并后原样写回(模拟 loader 仅 volatile 变化的原地热更)
306
+ function makeRc17Ctx({ configStore = {}, configEditorAvailable = true, configureAvailable = true } = {}) {
307
+ const routes = new Map()
308
+ const calls = { editCalls: [], configureCalls: [], registerCalls: [] }
309
+ const config = { get: () => ({ ...configStore }) }
310
+ const settingsService = {}
311
+ if (configureAvailable) {
312
+ settingsService.configure = (presentation, owner) => {
313
+ calls.configureCalls.push({ presentation, owner })
314
+ return () => {}
266
315
  }
267
- tick()
268
- })
316
+ }
317
+ const configEditor = {
318
+ async edit(entry, change) {
319
+ calls.editCalls.push(entry)
320
+ const next = change({ ...configStore }, {})
321
+ for (const [key, value] of Object.entries(next)) configStore[key] = value
322
+ },
323
+ }
324
+ const effects = []
325
+ const ctx = {
326
+ calls,
327
+ fiber: { entry: { options: { id: 'cron-board', name: '@mzzsfy/dsh-cron-board' } } },
328
+ effect(fn, label) {
329
+ // 会话等待轮询为真实定时器形态:桩跳过(与 makeFullCtx 同款),其余同步执行
330
+ if (label === 'cron-board session wait') return
331
+ effects.push(fn)
332
+ fn()
333
+ },
334
+ get(name) {
335
+ if (name === 'settings') return settingsService
336
+ if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
337
+ if (name === 'agents') return { get: () => undefined }
338
+ if (name === 'sessionQuery') return { listSessions: async () => [] }
339
+ if (name === 'workspaceRegistry') return { archivedSessionIds: [] }
340
+ if (name === 'agentPresets') return { defaultId: '', list: async () => [] }
341
+ return undefined
342
+ },
343
+ inject(deps, fn) {
344
+ if (deps[0] === 'timer') {
345
+ fn({ interval(intervalFn) { return () => {} } })
346
+ }
347
+ if (deps[0] === 'settings') {
348
+ fn({
349
+ settings: settingsService,
350
+ effect(stubEffect) {
351
+ const disposer = stubEffect()
352
+ if (typeof disposer === 'function') effects.push(disposer)
353
+ },
354
+ })
355
+ }
356
+ },
357
+ webServer: {
358
+ register(route) {
359
+ routes.set(route.path, route.handler)
360
+ return () => {}
361
+ },
362
+ },
363
+ }
364
+ return { ctx, routes, config, configStore, calls, effects }
269
365
  }
366
+
367
+ // ui-settings 请求走 prefix 路由(api.handle 按 method+segments 分发);
368
+ // 需真实数据目录(getRuntime 装配 store),env 注入临时目录
369
+ async function callApi(routes, path, body) {
370
+ const handler = routes.get('/api/cron-board')
371
+ assert.ok(handler, 'prefix 路由未注册')
372
+ const res = { status: null, payload: null }
373
+ res.writeHead = (status) => { res.status = status }
374
+ res.end = (text) => { res.payload = text ? JSON.parse(text) : null }
375
+ const req = new EventEmitter()
376
+ req.method = body === undefined ? 'GET' : 'POST'
377
+ req.url = 'http://localhost' + path
378
+ req.headers = { 'content-type': 'application/json' }
379
+ // 装配层初始化后才挂读体监听:桩事件须延后发射(与既有 post 桩同款)
380
+ setTimeout(() => {
381
+ if (body !== undefined) req.emit('data', Buffer.from(JSON.stringify(body)))
382
+ req.emit('end')
383
+ }, 80)
384
+ await handler(req, res)
385
+ return res
386
+ }
387
+
388
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', () => {
389
+ // Given 静态 Config 导出(0.1.7 节表单事实源)
390
+ assert.ok(mod.Config, 'Config 导出必须在场')
391
+ // When 空配置校验
392
+ const resolved = mod.Config['~standard'].validate({})
393
+ // Then 产整节单 ref(get 协议),字段为 schema 默认值
394
+ assert.equal(resolved.issues, undefined)
395
+ const section = resolved.value.get()
396
+ assert.equal(section.tickSeconds, 30)
397
+ assert.equal(section.maxConcurrent, 2)
398
+ assert.equal(section.logKeepPerJob, 200)
399
+ assert.equal(section.maskEnvInPrompt, false)
400
+ assert.equal(section.sidebarTab, false)
401
+ // When 显式值校验
402
+ // Then 值透传
403
+ const provided = mod.Config['~standard'].validate({ sidebarTab: true }).value.get()
404
+ assert.equal(provided.sidebarTab, true)
405
+ })
406
+
407
+ test('0.1.7 形态:ui-settings GET 读 config 节值(ref 解包),非默认值', async (t) => {
408
+ // Given settings 无 register/get 的宿主 + config ref 携带用户值
409
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
410
+ t.after(async () => {
411
+ delete process.env.DSH_CRON_BOARD_DATA_DIR
412
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
413
+ })
414
+ process.env.DSH_CRON_BOARD_DATA_DIR = dir
415
+ const { ctx, routes, config } = makeRc17Ctx({ configStore: { sidebarTab: true, tickSeconds: 60 } })
416
+ apply(ctx, config)
417
+ // When GET ui-settings
418
+ const res = await callApi(routes, '/api/cron-board/status')
419
+ // Then 返回 config 节值
420
+ assert.equal(res.status, 200)
421
+ assert.equal(res.payload.ui.sidebarTab, true)
422
+ })
423
+
424
+ test('0.1.7 形态:ui-settings POST 经 configEditor.edit 落 fiber.entry 且持久合并', async (t) => {
425
+ // Given configEditor 桩记录 edit 调用
426
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
427
+ t.after(async () => {
428
+ delete process.env.DSH_CRON_BOARD_DATA_DIR
429
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
430
+ })
431
+ process.env.DSH_CRON_BOARD_DATA_DIR = dir
432
+ const { ctx, routes, config, configStore, calls } = makeRc17Ctx({ configStore: { tickSeconds: 60 } })
433
+ apply(ctx, config)
434
+ // When POST 写 sidebarTab
435
+ const res = await callApi(routes, '/api/cron-board/ui-settings', { sidebarTab: true })
436
+ // Then edit 定位本条目,合并写回,响应 200 携新值
437
+ assert.equal(res.status, 200)
438
+ assert.equal(calls.editCalls.length, 1)
439
+ assert.equal(calls.editCalls[0].options.id, 'cron-board')
440
+ assert.equal(configStore.sidebarTab, true)
441
+ assert.equal(configStore.tickSeconds, 60)
442
+ assert.equal(res.payload.ui.sidebarTab, true)
443
+ })
444
+
445
+ test('0.1.7 形态:configEditor 缺失即 200 ok:false 拒写,不崩溃', async (t) => {
446
+ // Given configEditor 服务缺席
447
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
448
+ t.after(async () => {
449
+ delete process.env.DSH_CRON_BOARD_DATA_DIR
450
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
451
+ })
452
+ process.env.DSH_CRON_BOARD_DATA_DIR = dir
453
+ const { ctx, routes, config } = makeRc17Ctx({ configEditorAvailable: false })
454
+ apply(ctx, config)
455
+ // When POST 写设置
456
+ const res = await callApi(routes, '/api/cron-board/ui-settings', { sidebarTab: true })
457
+ // Then ok:false 拒写(api 层 persisted=false 语义),进程存活(测试走完即证)
458
+ assert.equal(res.status, 200)
459
+ assert.equal(res.payload.ok, false)
460
+ })
461
+
462
+ test('0.1.7 形态:settings.configure 在场即关闭原生自动页,缺席静默跳过', () => {
463
+ // Given configure 在场
464
+ const { ctx, calls, config } = makeRc17Ctx()
465
+ apply(ctx, config)
466
+ // Then 关闭自动页,owner 为本插件 fiber
467
+ assert.deepEqual(calls.configureCalls, [{ presentation: { auto: false }, owner: ctx.fiber }])
468
+ // Given configure 缺席
469
+ const bare = makeRc17Ctx({ configureAvailable: false })
470
+ // When apply
471
+ // Then 不抛错(测试走完即证)
472
+ apply(bare.ctx, bare.config)
473
+ })
474
+
475
+ test('0.1.7 形态:settings 注入回调不因 register 缺失抛错', () => {
476
+ // Given settings 桩全空方法面(比真实宿主更严)
477
+ const { ctx, config } = makeRc17Ctx({ configureAvailable: false })
478
+ // When apply
479
+ // Then 注入回调静默返回,无异常
480
+ apply(ctx, config)
481
+ })
@@ -324,10 +324,11 @@ test('status 路由:ui.sidebarTab 透出用户侧边栏移入偏好', async (t)
324
324
  })
325
325
 
326
326
  test('ui-settings 路由:布尔补丁经 updateUiSettings 写入并回读', async (t) => {
327
- // Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效)
327
+ // Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效);
328
+ // 写入桩 async 返回 true 镜像持久化成功契约(宿主 update 为异步面)
328
329
  let stored = false
329
330
  const { api } = await makeApi(t, {
330
- updateUiSettings: (patch) => { if (typeof patch.sidebarTab === 'boolean') stored = patch.sidebarTab },
331
+ updateUiSettings: async (patch) => { if (typeof patch.sidebarTab === 'boolean') { stored = patch.sidebarTab; return true } return false },
331
332
  readSidebarTab: () => stored,
332
333
  })
333
334
  // When POST 开关开
@@ -465,9 +466,9 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
465
466
  const { api } = await makeApi(t)
466
467
  // When 预览「每天 08:30」
467
468
  const ok = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *' })
468
- // Then 摘要含每天与时刻,三次触发时间戳齐备
469
+ // Then 摘要含每天与时刻并带宿主时区尾注,三次触发时间戳齐备
469
470
  assert.equal(ok.status, 200)
470
- assert.equal(ok.payload.summary, '每天 08:30')
471
+ assert.match(ok.payload.summary, /^每天 08:30\(宿主 UTC[+-]\d/)
471
472
  assert.equal(ok.payload.nextAt.length, 3)
472
473
  assert.ok(ok.payload.nextAt.every((value) => typeof value === 'number' && value > Date.now()))
473
474
  // When 非法表达式
@@ -477,6 +478,33 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
477
478
  assert.match(bad.payload.error, /cron/i)
478
479
  })
479
480
 
481
+ test('jobs 与 preview 路由:时区后缀表达式的调度与校验', async (t) => {
482
+ const { api } = await makeApi(t)
483
+ // Given 带 T+8 后缀的任务
484
+ const created = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz', kind: 'shell', command: 'x', schedule: '0 9 * * *T+8' })
485
+ assert.equal(created.status, 200)
486
+ // Then 默认触发点按东八区 9:00(01:00Z),与宿主自身时区无关
487
+ assert.equal(new Date(created.payload.nextRunAt).toISOString().slice(11, 16), '01:00')
488
+ // When 非法后缀创建任务
489
+ const bad = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz2', kind: 'shell', command: 'x', schedule: '0 9 * * *T+15' })
490
+ // Then 400 且报后缀不合法
491
+ assert.equal(bad.status, 400)
492
+ assert.match(bad.payload.error, /时区后缀不合法/)
493
+ // When 带后缀预览
494
+ const preview = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *T+8' })
495
+ // Then 摘要尾注与触发点均为东八区语义
496
+ assert.equal(preview.status, 200)
497
+ assert.equal(preview.payload.summary, '每天 08:30(UTC+8)')
498
+ assert.equal(new Date(preview.payload.nextAt[0]).toISOString().slice(11, 16), '00:30')
499
+ })
500
+
501
+ test('status 路由:暴露宿主时区偏移 serverTzOffset', async (t) => {
502
+ const { api } = await makeApi(t)
503
+ const res = await call(api, 'GET', '/api/cron-board/status')
504
+ assert.equal(res.status, 200)
505
+ assert.equal(typeof res.payload.serverTzOffset, 'number')
506
+ })
507
+
480
508
  test('runs 路由:无参数查全量,带 jobId 查单任务', async (t) => {
481
509
  // Given 两个任务各有一条运行记录
482
510
  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)
@@ -109,6 +109,11 @@ test('client.js 主页面双形态挂载契约(默认宿主全局面板;设置
109
109
  assert.match(source, /function CronBoardPluginCard/)
110
110
  assert.match(source, /'ui-settings'/)
111
111
  assert.match(source, /sidebarReady/)
112
+ // 插件管理页包详情配置卡(0.1.7-rc.1+ plugins.bundle.config,key=包名);两代槽位并存注册,
113
+ // 各自宿主只消费其一(旧宿主无 plugins.bundle.config 消费方,rc.1 无 settings.plugin.item 消费方)
114
+ assert.match(source, /const PKG_ID = '@mzzsfy\/dsh-cron-board'/)
115
+ assert.match(source, /ctx\.slots\.inject\('plugins\.bundle\.config'/)
116
+ assert.match(source, /name: 'plugins\.bundle\.config', key: PKG_ID/)
112
117
  // 挂载仲裁:偏好(status.ui.sidebarTab)驱动全局面板/扩展槽互斥
113
118
  assert.match(source, /applyPref\(\)/)
114
119
  assert.match(source, /ui\.sidebarTab/)