@mzzsfy/dsh-cron-board 0.1.0 → 0.2.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
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-cron-board
2
2
 
3
- 轻量级青龙面板 dsh 插件:环境变量集中管理 + 定时任务(cron)调度,执行体支持两类——本地脚本(dsh 宿主进程内 spawn)与 dsh 会话任务(定时在指定时间段向 dsh 会话发起任务,支持「每次新建会话」或「固定同一会话」)。
3
+ 轻量级定时任务看板 dsh 插件:环境变量集中管理 + 定时任务(cron)调度,执行体支持两类——本地脚本(dsh 宿主进程内 spawn)与 dsh 会话任务(定时在指定时间段向 dsh 会话发起任务,支持「每次新建会话」或「固定同一会话」)。
4
4
 
5
5
  设计文档见仓库 `docs/progress/feat-cron-board.md`(架构 / 数据模型 / API 契约 / BDD 场景 / 里程碑)。
6
6
 
@@ -18,19 +18,16 @@
18
18
 
19
19
  ## 界面挂载
20
20
 
21
- 三形态自适应,运行时自动切换:
21
+ 挂载权交给用户,运行期不自动切换:
22
22
 
23
- | 场景 | 形态 |
24
- |---|---|
25
- | 未安装 dsh-better-sidebar | 主界面:原生侧栏「定时任务」入口 + 中心列看板(激活时隐藏会话区,面板互斥) |
26
- | 已安装且当前会话打开了「定时任务」页签 | 扩展槽:better-sidebar tab 承载看板,主界面形态拆除 |
27
- | 已安装但页签未打开(未开过 / 被用户关闭 / 类型在 side card 设置中被禁用) | 主界面形态接管 |
28
-
29
- 页签开关实时联动(subscribeState),无需刷新;扩展槽缺失时主界面形态即唯一入口。
23
+ - **默认:主界面**。无论是否安装 dsh-better-sidebar,看板始终在主界面(原生侧栏「定时任务」入口 + 中心列看板,激活时隐藏会话区,面板互斥)
24
+ - **手动移入**:设置>插件 页「定时任务」卡片提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab(显式 openTab 落入工作台)并拆除主界面形态
25
+ - **不自动回退**:移入后页签关闭/类型禁用均不再自动返回主界面;把开关关掉即回到主界面形态(轮询感知,约一个 tick 周期内生效)
26
+ - **开关可用性**:已安装 better-sidebar 时可切换;未安装时开关呈禁用态仅展示(保持主界面,无副作用)
30
27
 
31
28
  ## 配置
32
29
 
33
- 设置页「定时任务」分区(`cron-board` 命名空间),四项均即时生效:
30
+ 设置页>插件 页「定时任务」卡片(`cron-board` 命名空间),各项均即时生效:`sidebarTab` 经卡片开关写入;其余项经配置文件或设置接口修改。
34
31
 
35
32
  | 配置项 | 说明 | 默认值 |
36
33
  |---|---|---|
@@ -38,11 +35,12 @@
38
35
  | `maxConcurrent` | 全局同时运行的执行单元上限 | 2 |
39
36
  | `logKeepPerJob` | 每任务保留的运行日志份数(超出按文件修改时间裁剪) | 200 |
40
37
  | `maskEnvInPrompt` | 会话任务投递文本中环境变量值打码 | 关 |
38
+ | `sidebarTab` | 看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面) | 关 |
41
39
 
42
40
  ## 依赖
43
41
 
44
42
  - `croner`:cron 表达式解析与触发点计算
45
- - 宿主服务:settings / timer(软依赖)、webServer、sessionController(硬依赖,cordis 门控——缺失时插件整体不激活,含脚本任务)
43
+ - 宿主服务:settings / timer(软依赖)、webServer;sessionController 在 apply 内探测(缺失时打日志干净禁用,插件整体不激活,含脚本任务;0.1.1-rc.2 及更早版本无此服务)
46
44
 
47
45
  ## 开发
48
46
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
- "description": "轻量级青龙面板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.1.0",
3
+ "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/api.mjs CHANGED
@@ -24,6 +24,7 @@ export const MESSAGES = {
24
24
  badWindowFormat: '窗口时间格式须为 HH:mm',
25
25
  jobNotFound: '任务不存在',
26
26
  runNotFound: '运行记录不存在',
27
+ sessionDisabled: '宿主会话服务不可用,会话任务通道已禁用',
27
28
  systemError: '操作失败(系统级错误,详见服务端日志)',
28
29
  }
29
30
 
@@ -164,7 +165,7 @@ function normalizeJob(body) {
164
165
  return normalized
165
166
  }
166
167
 
167
- export function createApi({ store, logger, executor, scheduler, periodic, logSystem }) {
168
+ export function createApi({ store, logger, executor, scheduler, periodic, sessionState, readSidebarTab, updateUiSettings, logSystem }) {
168
169
 
169
170
  const routes = [
170
171
  {
@@ -278,9 +279,27 @@ export function createApi({ store, logger, executor, scheduler, periodic, logSys
278
279
  timerReason: periodic && periodic.reason ? periodic.reason : null,
279
280
  scheduler: scheduler ? scheduler.status() : { active: 0, queued: 0 },
280
281
  nextAt: scheduler && scheduler.nextRunAt ? scheduler.nextRunAt() : null,
282
+ session: sessionState
283
+ ? { ready: Boolean(sessionState.ready), disabled: Boolean(sessionState.disabled) }
284
+ : { ready: false, disabled: false },
285
+ ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false },
281
286
  })
282
287
  },
283
288
  },
289
+ {
290
+ // 界面偏好写入(sidebarTab 等):settings.update 服务端持久化,即时生效
291
+ method: 'POST',
292
+ segments: ['ui-settings'],
293
+ handler: async ({ req, res }) => {
294
+ const body = await readJsonBody(req)
295
+ const patch = {}
296
+ if (typeof body.sidebarTab === 'boolean') patch.sidebarTab = body.sidebarTab
297
+ if (!Object.keys(patch).length) return sendJson(res, 400, { error: '无有效字段' })
298
+ if (typeof updateUiSettings !== 'function') return sendJson(res, 200, { ok: false, error: '设置服务不可用' })
299
+ updateUiSettings(patch)
300
+ sendJson(res, 200, { ok: true, ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false } })
301
+ },
302
+ },
284
303
  {
285
304
  // 编辑表单实时预览:croner 服务端解析,前端不引解析依赖(定案 §6.2)
286
305
  method: 'POST',
@@ -307,6 +326,9 @@ export function createApi({ store, logger, executor, scheduler, periodic, logSys
307
326
  handler: async ({ req, res }) => {
308
327
  const body = await readJsonBody(req)
309
328
  const fields = normalizeJob(body)
329
+ if (fields.kind === 'session' && sessionState && sessionState.disabled) {
330
+ return sendJson(res, 400, { error: MESSAGES.sessionDisabled })
331
+ }
310
332
  const row = await store.jobs.create(fields)
311
333
  sendJson(res, 200, row)
312
334
  },
package/src/client.js CHANGED
@@ -136,6 +136,8 @@ body{
136
136
  .cb-switch input[type="checkbox"]:disabled + .cb-switch__track{opacity:.4;cursor:not-allowed}
137
137
  .cb-switch:not(:has(input[type="checkbox"]:disabled)):hover .cb-switch__track{background:var(--cb-accent-deep);opacity:.85}
138
138
  .cb-switch input[type="checkbox"]:checked:not(:disabled) + .cb-switch__track:hover{background:var(--cb-accent-deep)}
139
+ .cb-settings{display:flex;flex-direction:column;gap:var(--cb-space-2);padding:var(--cb-space-2) 0}
140
+ .cb-settings__hint{font-size:var(--cb-font-sm);color:var(--cb-text-dim)}
139
141
  .cb-table{display:flex;flex-direction:column;gap:var(--cb-space-1);font-size:var(--cb-font-md)}
140
142
  .cb-row{display:flex;align-items:center;gap:var(--cb-space-3);border:1px solid var(--cb-border);border-radius:var(--cb-radius-md);padding:var(--cb-space-2) var(--cb-space-4);flex-wrap:wrap;transition:background .15s,border-color .15s}
141
143
  .cb-row:hover{background:var(--cb-hover)}
@@ -753,19 +755,58 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
753
755
  const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
754
756
  const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
755
757
 
756
- // better-sidebar tab:注册即单实例(single),visible 门控轮询;wsModel 供会话任务选工作区
758
+ // 设置>插件页卡片:sidebarTab 选项;better-sidebar 在场可切换,否则仅展示禁用态
759
+ function CronBoardPluginCard() {
760
+ const [enabled, setEnabled] = useState(null)
761
+ const [busy, setBusy] = useState(false)
762
+ const [sidebarReady, setSidebarReady] = useState(Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR)))
763
+ useEffect(() => {
764
+ let alive = true
765
+ request('GET', 'status').then((outcome) => {
766
+ if (alive) setEnabled(outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false)
767
+ })
768
+ return () => { alive = false }
769
+ }, [])
770
+ const toggle = async () => {
771
+ if (busy || enabled === null || !sidebarReady) return
772
+ setBusy(true)
773
+ const outcome = await request('POST', 'ui-settings', { sidebarTab: !enabled })
774
+ if (outcome.ok && outcome.data && outcome.data.ui) setEnabled(outcome.data.ui.sidebarTab === true)
775
+ setBusy(false)
776
+ }
777
+ return h('div', { className: 'cb-settings' },
778
+ switchToggle({
779
+ checked: enabled === true,
780
+ disabled: busy || enabled === null || !sidebarReady,
781
+ onChange: toggle,
782
+ label: '看板移入 better-sidebar 侧边栏',
783
+ }),
784
+ h('div', { className: 'cb-settings__hint' },
785
+ sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'),
786
+ )
787
+ }
788
+
789
+ // better-sidebar tab:注册即单实例(single);v0.19.0 起注册只入「可打开」目录,
790
+ // 须显式 openTab 落入底部工作台;wsModel 供会话任务选工作区
757
791
  function registerBoardTab(ctx, service, wsModel) {
758
- return ctx.effect(() => service.registerTab({
759
- id: TAB_ID,
760
- title: '定时任务',
761
- icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
762
- stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
763
- h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
764
- h('path', { d: 'M6 2v12M10 2v12' })),
765
- order: 46,
766
- single: true,
767
- component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
768
- }), 'cron-board sidebar tab')
792
+ return ctx.effect(() => {
793
+ const disposeTab = service.registerTab({
794
+ id: TAB_ID,
795
+ title: '定时任务',
796
+ icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
797
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
798
+ h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
799
+ h('path', { d: 'M6 2v12M10 2v12' })),
800
+ order: 46,
801
+ single: true,
802
+ component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
803
+ })
804
+ // 无会话时 openTab 静默不落,tab 仍注册在册(新建标签页菜单可见)
805
+ if (typeof service.openTab === 'function') {
806
+ try { service.openTab({ type: TAB_ID }) } catch { /* 打开失败不回滚注册 */ }
807
+ }
808
+ return disposeTab
809
+ }, 'cron-board sidebar tab')
769
810
  }
770
811
 
771
812
  function sidebarRoot() {
@@ -888,60 +929,61 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
888
929
  }
889
930
 
890
931
  return {
891
- // workspaces 由宿主 client 常驻提供;声明注入保证 apply 时已就绪(缺失则 fiber 未激活,即干净禁用)
892
- inject: ['workspaces'],
932
+ // workspaces 由宿主 client 常驻提供;slots 供设置页分区挂载(缺失则 fiber 未激活,即干净禁用)
933
+ inject: ['workspaces', 'slots'],
893
934
  apply(ctx) {
894
935
  ensureStyle(document)
895
936
  // 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
896
937
  const wsService = ctx.get('workspaces')
897
938
  const wsModel = wsService && wsService.list && typeof wsService.list.getSnapshot === 'function' ? wsService.list : null
898
- // 扩展槽接入:tab 注册常驻 + 页签联动;直挂与服务晚注册两条路径共用
899
- const attachBoardTab = (sidebar) => {
900
- registerBoardTab(ctx, sidebar, wsModel)
901
- const tabActive = () => {
902
- // 页签开着(哪怕类型被禁,better-sidebar 仍渲染已开页签)即扩展槽形态;没开(未开/被关/被禁)即主界面形态
903
- const snap = typeof sidebar.getSnapshot === 'function' ? sidebar.getSnapshot() : null
904
- const openTabs = snap && snap.state && Array.isArray(snap.state.openTabs) ? snap.state.openTabs : null
905
- if (openTabs) return openTabs.some((tab) => tab.type === TAB_ID)
906
- // 快照未就绪(会话未激活)的保守兜底:类型被禁视作主界面
907
- if (typeof sidebar.isTabEnabled === 'function') return sidebar.isTabEnabled(TAB_ID)
908
- return true
909
- }
910
- let fallback = null
911
- const syncFallback = () => {
912
- const active = tabActive()
913
- if (!active && !fallback) {
914
- fallback = mountStandaloneBoard(wsModel)
915
- ctx.effect(() => () => { if (fallback) { fallback(); fallback = null } }, 'cron-board tab-closed fallback')
916
- } else if (active && fallback) {
917
- fallback()
918
- fallback = null
919
- }
920
- }
921
- syncFallback()
922
- if (typeof sidebar.subscribeState === 'function') {
923
- ctx.effect(() => sidebar.subscribeState(syncFallback), 'cron-board tab-open watch')
939
+ // 挂载权交给用户:默认永远主界面;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
940
+ const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
941
+ let standalone = mountStandaloneBoard(wsModel)
942
+ let tabDisposer = null
943
+ let sidebarTabOn = false
944
+ // betterSidebar 服务软探测 + 晚注册追踪:出现/变化时按当前偏好重新决策
945
+ let currentSidebar = ctx.get('betterSidebar')
946
+ const applyPref = () => {
947
+ const wantTab = sidebarTabOn && currentSidebar !== undefined
948
+ if (wantTab && tabDisposer === null) {
949
+ standalone()
950
+ standalone = null
951
+ tabDisposer = attachBoardTab(currentSidebar)
952
+ } else if (!wantTab && tabDisposer !== null) {
953
+ tabDisposer()
954
+ tabDisposer = null
924
955
  }
925
- // 快照晚载入兜底:页签持久化恢复可能落在订阅注册之前且不再发事件,延迟重查一次纠正双形态并存
926
- const LATE_SYNC_MS = 2 * 1000
927
- const lateSync = setTimeout(syncFallback, LATE_SYNC_MS)
928
- ctx.effect(() => () => clearTimeout(lateSync), 'cron-board late sync')
929
- }
930
- // 路径一:apply 时服务已在,直接接入
931
- const sidebar = ctx.get('betterSidebar')
932
- if (sidebar !== undefined) {
933
- attachBoardTab(sidebar)
934
- return
956
+ if (!wantTab && standalone === null) standalone = mountStandaloneBoard(wsModel)
935
957
  }
936
- // 路径二:服务缺席先落主界面形态,服务就绪后拆主界面、接扩展槽(含页签联动)
937
- let standalone = mountStandaloneBoard(wsModel)
938
- let upgraded = false
939
- ctx.effect(() => () => { if (!upgraded) standalone() }, 'cron-board standalone board')
958
+ const prefWatch = setInterval(async () => {
959
+ try {
960
+ const outcome = await request('GET', 'status')
961
+ if (outcome.ok && outcome.data && typeof outcome.data.ui === 'object' && outcome.data.ui !== null) {
962
+ const next = outcome.data.ui.sidebarTab === true
963
+ if (next !== sidebarTabOn) {
964
+ sidebarTabOn = next
965
+ applyPref()
966
+ }
967
+ }
968
+ } catch { /* 轮询失败保形态不变,下轮重试 */ }
969
+ }, 5 * 1000)
970
+ ctx.effect(() => () => {
971
+ clearInterval(prefWatch)
972
+ if (standalone) standalone()
973
+ if (tabDisposer) tabDisposer()
974
+ }, 'cron-board mount arbitration')
975
+ // 设置>插件页卡片:key 配对 ns,宿主按 describe 命名空间分发;effect 随 fiber 回收
976
+ ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
977
+ yield ctx.slots.register(
978
+ { name: 'settings.plugin.item', key: 'cron-board', label: '定时任务' },
979
+ CronBoardPluginCard,
980
+ )
981
+ }), 'cron-board settings card')
940
982
  ctx.inject(['betterSidebar'], (bsCtx) => {
941
- upgraded = true
942
- standalone()
943
- attachBoardTab(bsCtx.betterSidebar)
983
+ currentSidebar = bsCtx.betterSidebar
984
+ applyPref()
944
985
  })
986
+ applyPref()
945
987
  },
946
988
  }
947
989
  }
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-cron-board Host 半区:轻量级青龙面板。环境变量集中管理 + 定时任务(cron)调度,
1
+ // dsh-cron-board Host 半区:轻量级定时任务看板。环境变量集中管理 + 定时任务(cron)调度,
2
2
  // 执行体两类:本地脚本(宿主进程内 spawn)与 dsh 会话任务(定时向 dsh 会话投递任务文本)。
3
3
  // 模块分层:store(持久化)/ logger(日志)/ executor(执行链)/ scheduler(调度)/ api(路由)。
4
4
  // timer 为软依赖(嵌套注入,缺失降级只影响自动调度);settings 缺省时读默认常量。
@@ -18,23 +18,29 @@ import { createStore } from './store.mjs'
18
18
 
19
19
  export const name = 'dsh-cron-board'
20
20
 
21
- // 会话任务与路由/定时同为本体能力:sessionController 硬依赖声明交 cordis 门控,
22
- // 服务未就绪/缺失时 fiber 不激活即整体干净禁用(apply 内同步软探测会撞异步挂载时序,mce 同款事故)
23
- export const inject = ['webServer', 'sessionController']
21
+ // 会话任务依赖的 sessionController 由 dsh-web-app 的 session-controller 条目挂载,
22
+ // 注册晚于同批条目的 apply(异步装配),且 0.1.1-rc.2 无此服务:inject 声明会在旧版
23
+ // 永久 pending 拖死 boot,apply 内同步探测又会抢在挂载前误判缺失(0.1.2-rc.1 实测
24
+ // 404)。唯一两头正确的形态:启动后异步轮询等待服务出现再装配,超时仍缺失即干净禁用
25
+ export const inject = ['webServer']
24
26
 
25
27
  // 数据目录 env 覆盖:测试注入临时目录(对齐 dsh-usage-panel 先例)
26
28
  const DATA_DIR_ENV = 'DSH_CRON_BOARD_DATA_DIR'
27
29
  const API_PREFIX = '/api/cron-board'
28
30
  const SETTINGS_NS = 'cron-board'
29
31
  const TIMER_UNAVAILABLE_REASON = '宿主定时服务不可用,自动调度已停用'
32
+ const SESSION_UNAVAILABLE_REASON = '宿主会话服务 sessionController 不可用(需 dsh-web-app 挂载 session-controller,0.1.1-rc.2 及更早版本缺失),会话任务通道禁用,脚本任务不受影响'
33
+ const SESSION_WAIT_MS = 15 * 1000
34
+ const SESSION_POLL_MS = 250
30
35
  const DEFAULT_LOG_KEEP_PER_JOB = 200
31
36
 
32
- // 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码
37
+ // 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码 / 侧边栏移入偏好
33
38
  const SETTINGS_SCHEMA = schemastery.object({
34
39
  tickSeconds: schemastery.number().min(MIN_TICK_MS / 1000).step(1).default(DEFAULT_TICK_MS / 1000),
35
40
  maxConcurrent: schemastery.number().min(1).step(1).default(DEFAULT_MAX_CONCURRENT),
36
41
  logKeepPerJob: schemastery.number().min(1).step(1).default(200),
37
42
  maskEnvInPrompt: schemastery.boolean().default(false).description('会话任务投递文本中环境变量打码'),
43
+ sidebarTab: schemastery.boolean().default(false).description('看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面)'),
38
44
  })
39
45
 
40
46
  export function resolveDataDir(env = process.env) {
@@ -44,10 +50,11 @@ export function resolveDataDir(env = process.env) {
44
50
  }
45
51
 
46
52
  export function apply(ctx, config) {
47
- const sessionController = ctx.sessionController
48
53
  const dataDir = resolveDataDir()
49
54
  // 装配惰性单例:首个请求 / timer 激活触发初始化,各组件只建一次
50
55
  let runtimePromise = null
56
+ // 会话通道就绪状态:等待期 API 已可服务脚本任务,就绪/禁用后状态定格
57
+ const sessionState = { ready: false, disabled: false }
51
58
  // 生命周期解绑引用:惰性装配完成前给安全空实现,装配后指向真实 scheduler
52
59
  const schedulerRef = { current: { dispose() {} } }
53
60
  const getRuntime = () => {
@@ -56,7 +63,7 @@ export function apply(ctx, config) {
56
63
  const store = await createStore({ dir: dataDir })
57
64
  const logger = createLogger({ rootDir: join(dataDir, 'logs') })
58
65
  const sessionDriver = createSessionDriver({
59
- sessionController,
66
+ getSessionController: () => sessionControllerRef.current,
60
67
  agents: ctx.get('agents'),
61
68
  sessionQuery: ctx.get('sessionQuery'),
62
69
  })
@@ -78,6 +85,9 @@ export function apply(ctx, config) {
78
85
  executor,
79
86
  scheduler,
80
87
  periodic,
88
+ sessionState,
89
+ readSidebarTab: () => readBoolean('sidebarTab', false),
90
+ updateUiSettings,
81
91
  logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
82
92
  })
83
93
  return { api, scheduler }
@@ -87,6 +97,31 @@ export function apply(ctx, config) {
87
97
  return runtimePromise
88
98
  }
89
99
 
100
+ // 会话通道异步就绪:轮询等待 dsh-web-app 挂载 sessionController(时序不可赌,
101
+ // 实测 0.1.2-rc.1 冷启动挂载晚于本插件 apply),超时仍缺失即按旧宿主对待
102
+ ctx.effect(() => {
103
+ const startedAt = Date.now()
104
+ const poll = setInterval(() => {
105
+ // ctx.get 不受 inject 门控(cordis 明文契约:缺失返回 undefined)
106
+ const sessionController = ctx.get('sessionController')
107
+ if (sessionController) {
108
+ sessionState.ready = true
109
+ sessionControllerRef.current = sessionController
110
+ clearInterval(poll)
111
+ return
112
+ }
113
+ if (Date.now() - startedAt >= SESSION_WAIT_MS) {
114
+ sessionState.disabled = true
115
+ // rc.2 的 cordis logger 默认 exporter 仅入内存缓冲无输出通道,告警必须直写 stderr 才可见
116
+ process.stderr.write('[cron-board] ' + SESSION_UNAVAILABLE_REASON + '\n')
117
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + SESSION_UNAVAILABLE_REASON)
118
+ clearInterval(poll)
119
+ }
120
+ }, SESSION_POLL_MS)
121
+ return () => clearInterval(poll)
122
+ }, 'cron-board session wait')
123
+ const sessionControllerRef = { current: null }
124
+
90
125
  const periodic = { running: false, reason: null }
91
126
  let armTick = null
92
127
  let disposeTick = null
@@ -99,6 +134,13 @@ export function apply(ctx, config) {
99
134
  const value = readSettingValue(name)
100
135
  return typeof value === 'boolean' ? value : fallback
101
136
  }
137
+ // 客户端设置面板写入通道:settings 服务在场时按命名空间合并补丁
138
+ function updateUiSettings(patch) {
139
+ const settings = ctx.get('settings')
140
+ if (!settings || typeof settings.update !== 'function') return false
141
+ settings.update(SETTINGS_NS, patch)
142
+ return true
143
+ }
102
144
  function readSettingValue(name) {
103
145
  const settings = ctx.get('settings')
104
146
  const scope = settings && settings.get ? settings.get(SETTINGS_NS) : undefined
@@ -145,20 +187,25 @@ export function apply(ctx, config) {
145
187
  })
146
188
 
147
189
  ctx.effect(
148
- () => ctx.webServer.register({
149
- kind: 'prefix',
150
- path: API_PREFIX,
151
- handler: async (req, res) => {
152
- try {
153
- const runtime = await getRuntime()
154
- await runtime.api.handle(req, res)
155
- } catch (error) {
156
- res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
157
- res.end(JSON.stringify({ error: '操作失败(系统级错误,详见服务端日志)' }))
158
- if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + String(error && error.stack || error))
159
- }
160
- },
161
- }),
190
+ () => {
191
+ const disposeRoute = ctx.webServer.register({
192
+ kind: 'prefix',
193
+ path: API_PREFIX,
194
+ handler: async (req, res) => {
195
+ try {
196
+ const runtime = await getRuntime()
197
+ await runtime.api.handle(req, res)
198
+ } catch (error) {
199
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
200
+ res.end(JSON.stringify({ error: '操作失败(系统级错误,详见服务端日志)' }))
201
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + String(error && error.stack || error))
202
+ }
203
+ },
204
+ })
205
+ return () => {
206
+ disposeRoute()
207
+ }
208
+ },
162
209
  'cron-board api',
163
210
  )
164
211
 
@@ -12,7 +12,7 @@ const REJECTED_MESSAGE = '会话输入投递被拒绝'
12
12
 
13
13
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
14
14
 
15
- export function createSessionDriver({ sessionController, agents, sessionQuery, pollIntervalMs = 2 * 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now() }) {
15
+ export function createSessionDriver({ getSessionController, agents, sessionQuery, pollIntervalMs = 2 * 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now() }) {
16
16
  // pinned 会话存在性:持久层 headers 比对(冷会话不在 agents 注册表)
17
17
  async function pinnedExists(sessionId) {
18
18
  if (!sessionQuery || typeof sessionQuery.listSessions !== 'function') return Boolean(agents.get(sessionId))
@@ -21,6 +21,9 @@ export function createSessionDriver({ sessionController, agents, sessionQuery, p
21
21
  }
22
22
 
23
23
  async function run({ job, env, mask, updateRecord }) {
24
+ // 控制器运行时动态解析:装配可能先于宿主挂载 sessionController(异步就绪等待中)
25
+ const sessionController = getSessionController ? getSessionController() : undefined
26
+ if (!sessionController) return { status: 'fail', message: '会话服务不可用' }
24
27
  const session = job.session || {}
25
28
  const isPinned = session.mode === 'pinned'
26
29
  let sessionId = isPinned ? session.pinnedSessionId : null
package/src/store.mjs CHANGED
@@ -181,6 +181,8 @@ export async function createStore({ dir }) {
181
181
  return {
182
182
  envs,
183
183
  jobs,
184
+ // 写链排空:等待已提交 mutate 全部结束(不报告写失败);测试清理数据目录前调用以缩小与删除的竞态面
185
+ flush: () => link.chain.catch(() => {}),
184
186
  runs: {
185
187
  get rows() {
186
188
  return runs.rows
@@ -1,4 +1,4 @@
1
- // 环境变量同名多值展开测试(青龙多账号语义,设计 §4.2)。
1
+ // 环境变量同名多值展开测试(多账号语义,设计 §4.2)。
2
2
 
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
@@ -19,15 +19,19 @@ test('index:数据目录默认 ~/.dsh/cron-board,env 可覆盖', () => {
19
19
  })
20
20
 
21
21
  test('index:apply 注册 prefix 路由且请求走通(列表接口)', async () => {
22
- // Given webServer 桩:收集注册路由(sessionController 属性桩满足 inject 门控后环境)
22
+ // Given webServer 桩:收集注册路由(sessionController 经 get 桩供给,就绪等待同步完成)
23
23
  const routes = new Map()
24
+ const sessionController = { create: async () => ({ sessionId: 's-x' }), prompt: async () => ({ accepted: true }) }
24
25
  const ctx = {
25
- effect(fn) {
26
+ effect(fn, label) {
27
+ if (label === 'cron-board session wait') { fn(); return }
26
28
  fn()
27
29
  },
28
30
  inject() {},
29
- sessionController: { create: async () => ({ sessionId: 's-x' }), prompt: async () => ({ accepted: true }) },
30
- get: () => undefined,
31
+ get(name) {
32
+ if (name === 'sessionController') return sessionController
33
+ return undefined
34
+ },
31
35
  webServer: {
32
36
  register(route) {
33
37
  routes.set(route.path, route.handler)
@@ -80,10 +84,11 @@ function makeFullCtx({ timerAvailable = true } = {}) {
80
84
  }
81
85
  const intervals = []
82
86
  const ctx = {
83
- effect(fn) {
87
+ effect(fn, label) {
88
+ // 会话等待 effect 是真实定时器轮询形态,桩跳过直接执行体;controller 经 get 即可判
89
+ if (label === 'cron-board session wait') { fn(); return }
84
90
  fn()
85
91
  },
86
- sessionController,
87
92
  get(name) {
88
93
  if (name === 'settings') return settingsService
89
94
  if (name === 'sessionController') return sessionController
@@ -114,10 +119,40 @@ function makeFullCtx({ timerAvailable = true } = {}) {
114
119
  return { ctx, routes, registered, intervals, settingsService, sessions }
115
120
  }
116
121
 
117
- test('index:inject 声明 webServer+sessionController(cordis 门控整体禁用的唯一形态)', async () => {
118
- // Given 宿主会话服务走 cordis 硬依赖门控:缺失时 fiber 不激活,apply 不执行
119
- // Then 静态锁定 inject 声明(修复 mce 同款「apply 内同步探测误判缺失」事故形态)
120
- assert.deepEqual(mod.inject, ['webServer', 'sessionController'])
122
+ test('index:inject 仅声明 webServer(sessionController 异步就绪等待,不进 inject)', async () => {
123
+ // Given sessionController 由 dsh-web-app 条目异步挂载:inject 声明在 0.1.1-rc.2 上
124
+ // 永久 pending 拖死 boot,apply 内同步探测又抢在挂载前误判(0.1.2-rc.1 实测 404)
125
+ // Then 静态锁定 inject 声明;就绪语义由 effect 内轮询等待 + 超时禁用承担
126
+ assert.deepEqual(mod.inject, ['webServer'])
127
+ })
128
+
129
+ test('index:sessionController 恒缺失时超时干净禁用会话通道(脚本任务不受影响)', async () => {
130
+ // Given 无 sessionController 的宿主上下文(等价 0.1.1-rc.2)
131
+ const routes = new Map()
132
+ const warnings = []
133
+ const effects = []
134
+ const ctx = {
135
+ effect(fn, label) {
136
+ if (label === 'cron-board session wait') effects.push(fn)
137
+ else fn()
138
+ },
139
+ inject() {},
140
+ logger: { warn: (line) => warnings.push(line) },
141
+ get: () => undefined,
142
+ webServer: {
143
+ register(route) {
144
+ routes.set(route.path, route.handler)
145
+ return () => {}
146
+ },
147
+ },
148
+ }
149
+ // When apply(路由同步注册,会话等待 effect 挂起)
150
+ apply(ctx)
151
+ // Then 路由已注册(脚本任务可用),等待 effect 在列
152
+ assert.equal(routes.size, 1)
153
+ assert.equal(effects.length, 1)
154
+ // When 超时轮询终结(缩短等待:直接跑完等待窗)
155
+ effects[0]()
121
156
  })
122
157
 
123
158
  test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期 tick', async () => {
@@ -15,8 +15,13 @@ import { createApi } from '../src/api.mjs'
15
15
 
16
16
  async function makeApi(t, overrides = {}) {
17
17
  const dir = await mkdtemp(join(tmpdir(), 'cron-board-api-'))
18
- t.after(() => rm(dir, { recursive: true, force: true }))
19
18
  const store = await createStore({ dir: join(dir, 'data') })
19
+ // 清理前等写链排空:终态只保证内存可见,收尾写(任务卡回填、日志裁剪)提交晚于 flush,
20
+ // rmdir 与其赛跑会 ENOTEMPTY;retries 吸收(镜像 executor/scheduler 测试同款参数)
21
+ t.after(async () => {
22
+ await store.flush()
23
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
24
+ })
20
25
  const logger = createLogger({ rootDir: join(dir, 'logs') })
21
26
  const executor = createExecutor({ store, logger, readMaxConcurrent: () => 2 })
22
27
  const api = createApi({ store, logger, executor, ...overrides })
@@ -239,6 +244,27 @@ test('jobs 路由:同名多值展开为多条 RunRecord 与多次运行', async
239
244
  assert.deepEqual([...logs].sort(), ['one', 'two'])
240
245
  })
241
246
 
247
+ test('jobs 路由:慢盘下运行终态后清理不与收尾写赛跑(ENOTEMPTY 回归)', async (t) => {
248
+ // Given 持久化注入慢盘延迟(放大终态内存可见与收尾写落盘的窗口)
249
+ const { store, api } = await makeApi(t)
250
+ const rawUpdate = store.runs.update.bind(store.runs)
251
+ store.runs.update = (runId, patch) => new Promise((resolve) => {
252
+ setTimeout(() => rawUpdate(runId, patch).then(resolve, resolve), 25)
253
+ })
254
+ const job = await store.jobs.create({
255
+ name: 'slowdisk', kind: 'shell',
256
+ command: `${process.execPath} -e ""`,
257
+ schedule: '* * * * *', enabled: true, timeoutMs: 10 * 1000,
258
+ })
259
+ // When 手动运行到终态后测试即结束(清理走 makeApi 的 flush+retries)
260
+ const runRes = await call(api, 'POST', '/api/cron-board/jobs/' + job.id + '/run')
261
+ const { runIds } = runRes.payload
262
+ await waitFor(() => {
263
+ const row = store.runs.get(runIds[0])
264
+ return row && row.status !== 'queued' && row.status !== 'running' ? row : null
265
+ })
266
+ })
267
+
242
268
  test('status 路由:报告调度器与 timer 可用性', async (t) => {
243
269
  // Given scheduler 桩与 timer 运行标记
244
270
  const { api } = await makeApi(t, {
@@ -247,11 +273,47 @@ test('status 路由:报告调度器与 timer 可用性', async (t) => {
247
273
  })
248
274
  // When GET /status
249
275
  const res = await call(api, 'GET', '/api/cron-board/status')
250
- // Then 状态齐备
276
+ // Then 状态齐备;ui 偏好缺省为关(默认主界面)
251
277
  assert.equal(res.status, 200)
252
278
  assert.equal(res.payload.timerRunning, true)
253
279
  assert.deepEqual(res.payload.scheduler, { active: 1, queued: 2 })
254
280
  assert.equal(res.payload.nextAt, 1750000000000)
281
+ assert.deepEqual(res.payload.ui, { sidebarTab: false })
282
+ })
283
+
284
+ test('status 路由:ui.sidebarTab 透出用户侧边栏移入偏好', async (t) => {
285
+ // Given readSidebarTab 桩返回开
286
+ const { api } = await makeApi(t, { readSidebarTab: () => true })
287
+ // When GET /status
288
+ const res = await call(api, 'GET', '/api/cron-board/status')
289
+ // Then ui.sidebarTab=true(client 据此移入扩展槽)
290
+ assert.equal(res.payload.ui.sidebarTab, true)
291
+ })
292
+
293
+ test('ui-settings 路由:布尔补丁经 updateUiSettings 写入并回读', async (t) => {
294
+ // Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效)
295
+ let stored = false
296
+ const { api } = await makeApi(t, {
297
+ updateUiSettings: (patch) => { if (typeof patch.sidebarTab === 'boolean') stored = patch.sidebarTab },
298
+ readSidebarTab: () => stored,
299
+ })
300
+ // When POST 开关开
301
+ const res = await call(api, 'POST', '/api/cron-board/ui-settings', { sidebarTab: true })
302
+ // Then ok 且回读 true;写桩收到布尔补丁
303
+ assert.equal(res.status, 200)
304
+ assert.equal(res.payload.ok, true)
305
+ assert.equal(res.payload.ui.sidebarTab, true)
306
+ assert.equal(stored, true)
307
+ })
308
+
309
+ test('ui-settings 路由:无效字段 400,服务缺失降级 ok=false', async (t) => {
310
+ const noHandler = await makeApi(t, { updateUiSettings: undefined })
311
+ const res1 = await call(noHandler.api, 'POST', '/api/cron-board/ui-settings', { sidebarTab: true })
312
+ assert.equal(res1.status, 200)
313
+ assert.equal(res1.payload.ok, false)
314
+ const { api } = await makeApi(t)
315
+ const res2 = await call(api, 'POST', '/api/cron-board/ui-settings', { other: 1 })
316
+ assert.equal(res2.status, 400)
255
317
  })
256
318
 
257
319
  test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', async (t) => {
@@ -304,6 +366,28 @@ test('jobs 路由:会话任务字段校验与默认值', async (t) => {
304
366
  assert.equal(badMiss.status, 400)
305
367
  })
306
368
 
369
+ test('jobs 路由:会话通道禁用时建会话任务 400,脚本任务不受限', async (t) => {
370
+ // Given sessionState.disabled(等价 0.1.1-rc.2 超时禁用态)
371
+ const sessionState = { ready: false, disabled: true }
372
+ const { api } = await makeApi(t, { sessionState })
373
+ const sess = await call(api, 'POST', '/api/cron-board/jobs', {
374
+ name: 'sv', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
375
+ })
376
+ assert.equal(sess.status, 400)
377
+ assert.match(sess.payload.error, /会话任务通道已禁用/)
378
+ const shell = await call(api, 'POST', '/api/cron-board/jobs', {
379
+ name: 'sh', kind: 'shell', command: 'echo x', schedule: '* * * * *', enabled: true,
380
+ })
381
+ assert.equal(shell.status, 200)
382
+ })
383
+
384
+ test('status 路由:透出会话通道就绪状态', async (t) => {
385
+ const sessionState = { ready: true, disabled: false }
386
+ const { api } = await makeApi(t, { sessionState })
387
+ const res = await call(api, 'GET', '/api/cron-board/status')
388
+ assert.deepEqual(res.payload.session, { ready: true, disabled: false })
389
+ })
390
+
307
391
  test('cron preview 路由:人话摘要与下三次触发', async (t) => {
308
392
  const { api } = await makeApi(t)
309
393
  // When 预览「每天 08:30」
@@ -44,7 +44,7 @@ function makeStubs({ autoIdle = true } = {}) {
44
44
 
45
45
  function makeDriver(stubs, overrides = {}) {
46
46
  return createSessionDriver({
47
- sessionController: stubs.sessionController,
47
+ getSessionController: () => stubs.sessionController,
48
48
  agents: stubs.agents,
49
49
  sessionQuery: stubs.sessionQuery,
50
50
  pollIntervalMs: 5,
@@ -70,7 +70,7 @@ test('store:同名环境变量多条合法', async (t) => {
70
70
  await store.envs.create({ name: 'WX_KEY', value: 'a', remarks: '', enabled: true })
71
71
  // When 再创建同名不同值
72
72
  await store.envs.create({ name: 'WX_KEY', value: 'b', remarks: '', enabled: true })
73
- // Then 两条并存(青龙多账号语义)
73
+ // Then 两条并存(多账号语义)
74
74
  const same = store.envs.list().filter((row) => row.name === 'WX_KEY')
75
75
  assert.equal(same.length, 2)
76
76
  })
@@ -48,9 +48,10 @@ test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
48
48
  assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
49
49
  })
50
50
 
51
- test('client.js 主页面双形态挂载契约(better-sidebar 优先/主界面回退)', () => {
52
- // Given 用户要求:面板入口迁主页面,优先 dsh-better-sidebar 扩展槽(单实例),未装回退主界面(taskboard 形态)
53
- // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件
51
+ test('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
52
+ // Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
53
+ // 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)
54
+ // Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件、偏好驱动仲裁
54
55
  assert.match(source, /window\.__ModuleLoader__\.load\(\{ id: '@mzzsfy\/dsh-cron-board', factory \}\)/)
55
56
  assert.match(source, /ctx\.get\('betterSidebar'\)/)
56
57
  assert.match(source, /single: true/)
@@ -58,6 +59,17 @@ test('client.js 主页面双形态挂载契约(better-sidebar 优先/主界面
58
59
  assert.match(source, /data-cb-board-active/)
59
60
  assert.match(source, /dsh-panel-activate/)
60
61
  assert.match(source, /function mountStandaloneBoard/)
61
- // 不再挂设置页分区(入口唯一)
62
- assert.doesNotMatch(source, /settings\.section/)
62
+ // 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 在场可切换,否则禁用仅展示
63
+ assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
64
+ assert.match(source, /key: 'cron-board', label: '定时任务'/)
65
+ assert.match(source, /function CronBoardPluginCard/)
66
+ assert.match(source, /'ui-settings'/)
67
+ assert.match(source, /sidebarReady/)
68
+ // 挂载仲裁:apply 无条件先落主界面形态;偏好(status.ui.sidebarTab)驱动接入/退出
69
+ assert.match(source, /let standalone = mountStandaloneBoard\(wsModel\)/)
70
+ assert.match(source, /applyPref\(\)/)
71
+ assert.match(source, /ui\.sidebarTab/)
72
+ // 不再自动回退:attach 后不订阅页签状态,无 openTabs/subscribeState 联动
73
+ assert.doesNotMatch(source, /subscribeState/)
74
+ assert.doesNotMatch(source, /openTabs/)
63
75
  })