@mzzsfy/dsh-cron-board 0.5.0 → 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
@@ -55,4 +55,4 @@ node --test "test/*.test.mjs" # 测试
55
55
 
56
56
  ## dsh 版本兼容
57
57
 
58
- 三版本全部通过:cron 看板渲染与启停、与侧边栏联动(侧栏标签页形态)、激活 live。0.1.6 新侧栏标签页系统下联动开关双态均验证通过。
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.5.0",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/api.mjs CHANGED
@@ -297,8 +297,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
297
297
  if (typeof body.sidebarTab === 'boolean') patch.sidebarTab = body.sidebarTab
298
298
  if (!Object.keys(patch).length) return sendJson(res, 400, { error: '无有效字段' })
299
299
  if (typeof updateUiSettings !== 'function') return sendJson(res, 200, { ok: false, error: '设置服务不可用' })
300
- updateUiSettings(patch)
301
- 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 } })
302
302
  },
303
303
  },
304
304
  {
package/src/client.js CHANGED
@@ -1069,6 +1069,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1069
1069
  const TAB_ID = 'cron-board:board'
1070
1070
  const PANEL_ID = 'cron-board'
1071
1071
  const PANEL_LABEL = '定时任务'
1072
+ const PKG_ID = '@mzzsfy/dsh-cron-board'
1072
1073
 
1073
1074
  // 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
1074
1075
  const CARD_TITLE = '定时任务'
@@ -1219,6 +1220,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1219
1220
  () => h(CronBoardPluginCard, { ctx }),
1220
1221
  )
1221
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')
1222
1232
  ctx.inject(['betterSidebar'], (bsCtx) => {
1223
1233
  currentSidebar = bsCtx.betterSidebar
1224
1234
  applyPref()
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') {
@@ -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
@@ -264,7 +294,188 @@ function waitForRecord(poll, timeoutMs = 10 * 1000) {
264
294
  }
265
295
  setTimeout(tick, 50)
266
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 () => {}
267
315
  }
268
- tick()
269
- })
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 }
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
270
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 开关开
@@ -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/)