@mzzsfy/dsh-cron-board 0.1.1 → 0.2.1
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 +8 -10
- package/package.json +2 -2
- package/src/api.mjs +23 -1
- package/src/client.js +134 -59
- package/src/index.js +67 -28
- package/src/session-driver.mjs +4 -1
- package/test/env-expand.test.mjs +1 -1
- package/test/index.test.mjs +22 -19
- package/test/routes.test.mjs +59 -1
- package/test/session-driver.test.mjs +1 -1
- package/test/store.test.mjs +1 -1
- package/test/switch-guard.test.mjs +29 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mzzsfy/dsh-cron-board
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
| 已安装但页签未打开(未开过 / 被用户关闭 / 类型在 side card 设置中被禁用) | 主界面形态接管 |
|
|
28
|
-
|
|
29
|
-
页签开关实时联动(subscribeState),无需刷新;扩展槽缺失时主界面形态即唯一入口。
|
|
23
|
+
- **默认:主界面**。无论是否安装 dsh-better-sidebar,看板始终在主界面(原生侧栏「定时任务」入口 + 中心列看板,激活时隐藏会话区,面板互斥)
|
|
24
|
+
- **手动移入**:设置>插件 页「定时任务」卡片(官方 PluginCard 形制:折叠卡壳,cb-pc 镜像)提供「看板移入 better-sidebar 侧边栏」开关;开启即注册扩展槽 tab(显式 openTab 落入工作台)并拆除主界面形态
|
|
25
|
+
- **不自动回退**:移入后页签关闭/类型禁用均不再自动返回主界面;把开关关掉即回到主界面形态(轮询感知,约一个 tick 周期内生效)
|
|
26
|
+
- **开关可用性**:已安装 better-sidebar 时可切换;未安装时开关呈禁用态仅展示(保持主界面,无副作用)
|
|
30
27
|
|
|
31
28
|
## 配置
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
设置页>插件 页「定时任务」卡片(`cron-board` 命名空间),各项均即时生效:`sidebarTab` 经卡片开关写入;其余项经配置文件或设置接口修改。
|
|
34
31
|
|
|
35
32
|
| 配置项 | 说明 | 默认值 |
|
|
36
33
|
|---|---|---|
|
|
@@ -38,6 +35,7 @@
|
|
|
38
35
|
| `maxConcurrent` | 全局同时运行的执行单元上限 | 2 |
|
|
39
36
|
| `logKeepPerJob` | 每任务保留的运行日志份数(超出按文件修改时间裁剪) | 200 |
|
|
40
37
|
| `maskEnvInPrompt` | 会话任务投递文本中环境变量值打码 | 关 |
|
|
38
|
+
| `sidebarTab` | 看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面) | 关 |
|
|
41
39
|
|
|
42
40
|
## 依赖
|
|
43
41
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-cron-board",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
|
|
4
|
+
"version": "0.2.1",
|
|
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,23 @@ 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-pc):镜像官方 PluginCard/SubagentModelSelectionCard 形制,数值与令牌直取官方 CSS Module;
|
|
140
|
+
官方壳未导出,slot 契约声明卡片外观归插件自持,同页同 dsw-alias 令牌即同观感(含深色主题) */
|
|
141
|
+
.cb-pc{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}
|
|
142
|
+
.cb-pc:hover{border-color:var(--dsw-alias-label-dimmed)}
|
|
143
|
+
.cb-pc--open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
|
144
|
+
.cb-pc__head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
|
|
145
|
+
.cb-pc__head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
146
|
+
.cb-pc__text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
|
|
147
|
+
.cb-pc__name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
|
|
148
|
+
.cb-pc__desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
149
|
+
.cb-pc__chevron{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex;transition:transform .16s}
|
|
150
|
+
.cb-pc--open .cb-pc__chevron{transform:rotate(180deg)}
|
|
151
|
+
.cb-pc__body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
|
152
|
+
.cb-pc__row{gap:6px;padding:12px 0;display:grid}
|
|
153
|
+
.cb-pc__rowLine{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:flex-start;gap:16px;font-size:13px;line-height:1.5;display:flex}
|
|
154
|
+
.cb-pc__rowLabel{flex:1;min-width:0}
|
|
155
|
+
.cb-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
|
139
156
|
.cb-table{display:flex;flex-direction:column;gap:var(--cb-space-1);font-size:var(--cb-font-md)}
|
|
140
157
|
.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
158
|
.cb-row:hover{background:var(--cb-hover)}
|
|
@@ -145,7 +162,7 @@ body{
|
|
|
145
162
|
.cb-modal{background:var(--cb-bg);color:var(--cb-text);border:1px solid var(--cb-border);border-radius:var(--cb-radius-lg);padding:var(--cb-space-6);max-width:720px;width:min(720px,92vw);max-height:84vh;overflow:auto;display:flex;flex-direction:column;gap:var(--cb-space-4);box-shadow:0 12px 40px rgb(31 35 41 / 18%);animation:cb-pop-in .2s ease-out}
|
|
146
163
|
@keyframes cb-fade-in{from{opacity:0}to{opacity:1}}
|
|
147
164
|
@keyframes cb-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
|
148
|
-
@media (prefers-reduced-motion: reduce){.cb-modal-mask,.cb-modal{animation:none}.cb-card,.cb-row,.cb-button,.cb-pill,.cb-icon,.cb-switch__track{transition:none}}
|
|
165
|
+
@media (prefers-reduced-motion: reduce){.cb-modal-mask,.cb-modal{animation:none}.cb-card,.cb-row,.cb-button,.cb-pill,.cb-icon,.cb-switch__track,.cb-pc,.cb-pc__chevron{transition:none}}
|
|
149
166
|
.cb-modal-head{display:flex;align-items:center;justify-content:space-between;gap:var(--cb-space-3);padding-bottom:var(--cb-space-3);border-bottom:1px solid var(--cb-border)}
|
|
150
167
|
.cb-modal-title{font-size:16px;font-weight:600}
|
|
151
168
|
.cb-modal-body{display:flex;flex-direction:column;gap:var(--cb-space-5)}
|
|
@@ -237,7 +254,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
237
254
|
// 规约 switch:input 锚定状态选择器,视觉由 track+thumb 呈现
|
|
238
255
|
function switchToggle(props) {
|
|
239
256
|
return h('label', { className: 'cb-switch' },
|
|
240
|
-
h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange
|
|
257
|
+
h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange,
|
|
258
|
+
'aria-label': props.ariaLabel }),
|
|
241
259
|
h('span', { className: 'cb-switch__track' }, h('span', { className: 'cb-switch__thumb' })),
|
|
242
260
|
props.label ? h('span', { className: 'cb-meta' }, props.label) : null)
|
|
243
261
|
}
|
|
@@ -753,19 +771,77 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
753
771
|
const OTHER_PANEL_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
|
|
754
772
|
const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
|
|
755
773
|
|
|
756
|
-
// better-sidebar
|
|
774
|
+
// 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
|
|
775
|
+
const CARD_TITLE = '定时任务'
|
|
776
|
+
const CARD_TOGGLE_LABEL = '看板移入 better-sidebar 侧边栏'
|
|
777
|
+
const readSidebarTabPref = (outcome) => outcome.ok && outcome.data &&
|
|
778
|
+
typeof outcome.data.ui === 'object' && outcome.data.ui !== null && outcome.data.ui.sidebarTab === true
|
|
779
|
+
const CHEVRON_DOWN = () => h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, fill: 'none',
|
|
780
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
781
|
+
h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
|
|
782
|
+
|
|
783
|
+
function CronBoardPluginCard() {
|
|
784
|
+
const [enabled, setEnabled] = useState(null)
|
|
785
|
+
const [busy, setBusy] = useState(false)
|
|
786
|
+
const [open, setOpen] = useState(false)
|
|
787
|
+
// 渲染时重探:侧栏面晚于设置页挂载时,enabled 到达的重渲染会自动纠正禁用态
|
|
788
|
+
const sidebarReady = Boolean(document.querySelector(SIDEBAR_ROOT_SELECTOR))
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
let alive = true
|
|
791
|
+
request('GET', 'status').then((outcome) => {
|
|
792
|
+
if (alive) setEnabled(readSidebarTabPref(outcome))
|
|
793
|
+
})
|
|
794
|
+
return () => { alive = false }
|
|
795
|
+
}, [])
|
|
796
|
+
const toggle = async () => {
|
|
797
|
+
if (busy || enabled === null || !sidebarReady) return
|
|
798
|
+
setBusy(true)
|
|
799
|
+
const outcome = await request('POST', 'ui-settings', { sidebarTab: !enabled })
|
|
800
|
+
if (outcome.ok && outcome.data && outcome.data.ui) setEnabled(outcome.data.ui.sidebarTab === true)
|
|
801
|
+
setBusy(false)
|
|
802
|
+
}
|
|
803
|
+
return h('li', { className: 'cb-pc' + (open ? ' cb-pc--open' : '') },
|
|
804
|
+
h('button', { type: 'button', className: 'cb-pc__head', 'aria-expanded': open,
|
|
805
|
+
'aria-label': (open ? '折叠' : '展开') + ': ' + CARD_TITLE, onClick: () => setOpen(!open) },
|
|
806
|
+
h('span', { className: 'cb-pc__text' },
|
|
807
|
+
h('span', { className: 'cb-pc__name' }, CARD_TITLE),
|
|
808
|
+
h('span', { className: 'cb-pc__desc' }, '计划任务看板、环境变量与执行日志')),
|
|
809
|
+
h('span', { className: 'cb-pc__chevron' }, h(CHEVRON_DOWN))),
|
|
810
|
+
open ? h('div', { className: 'cb-pc__body' },
|
|
811
|
+
h('div', { className: 'cb-pc__row' },
|
|
812
|
+
h('div', { className: 'cb-pc__rowLine' },
|
|
813
|
+
h('span', { className: 'cb-pc__rowLabel' }, CARD_TOGGLE_LABEL),
|
|
814
|
+
switchToggle({
|
|
815
|
+
checked: enabled === true,
|
|
816
|
+
disabled: busy || enabled === null || !sidebarReady,
|
|
817
|
+
onChange: toggle,
|
|
818
|
+
ariaLabel: CARD_TOGGLE_LABEL,
|
|
819
|
+
})),
|
|
820
|
+
h('p', { className: 'cb-pc__hint' },
|
|
821
|
+
sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时始终使用主界面。' : '需安装 better-sidebar 后方可切换;未安装时看板始终使用主界面。'))) : null)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// better-sidebar tab:注册即单实例(single);v0.19.0 起注册只入「可打开」目录,
|
|
825
|
+
// 须显式 openTab 落入底部工作台;wsModel 供会话任务选工作区
|
|
757
826
|
function registerBoardTab(ctx, service, wsModel) {
|
|
758
|
-
return ctx.effect(() =>
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
827
|
+
return ctx.effect(() => {
|
|
828
|
+
const disposeTab = service.registerTab({
|
|
829
|
+
id: TAB_ID,
|
|
830
|
+
title: '定时任务',
|
|
831
|
+
icon: (size) => h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
|
|
832
|
+
stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
833
|
+
h('rect', { x: 2, y: 2, width: 12, height: 12, rx: 2 }),
|
|
834
|
+
h('path', { d: 'M6 2v12M10 2v12' })),
|
|
835
|
+
order: 46,
|
|
836
|
+
single: true,
|
|
837
|
+
component: (props) => h(CronBoardPanel, { visible: props.visible, wsModel }),
|
|
838
|
+
})
|
|
839
|
+
// 无会话时 openTab 静默不落,tab 仍注册在册(新建标签页菜单可见)
|
|
840
|
+
if (typeof service.openTab === 'function') {
|
|
841
|
+
try { service.openTab({ type: TAB_ID }) } catch { /* 打开失败不回滚注册 */ }
|
|
842
|
+
}
|
|
843
|
+
return disposeTab
|
|
844
|
+
}, 'cron-board sidebar tab')
|
|
769
845
|
}
|
|
770
846
|
|
|
771
847
|
function sidebarRoot() {
|
|
@@ -888,60 +964,59 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
888
964
|
}
|
|
889
965
|
|
|
890
966
|
return {
|
|
891
|
-
// workspaces 由宿主 client
|
|
892
|
-
inject: ['workspaces'],
|
|
967
|
+
// workspaces 由宿主 client 常驻提供;slots 供设置页分区挂载(缺失则 fiber 未激活,即干净禁用)
|
|
968
|
+
inject: ['workspaces', 'slots'],
|
|
893
969
|
apply(ctx) {
|
|
894
970
|
ensureStyle(document)
|
|
895
971
|
// 工作区 model(list 即 getSnapshot/subscribe 契约的响应式模型;形态不符传 null,表单仅保留手输)
|
|
896
972
|
const wsService = ctx.get('workspaces')
|
|
897
973
|
const wsModel = wsService && wsService.list && typeof wsService.list.getSnapshot === 'function' ? wsService.list : null
|
|
898
|
-
//
|
|
899
|
-
const attachBoardTab = (sidebar) =>
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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')
|
|
974
|
+
// 挂载权交给用户:默认永远主界面;设置 sidebarTab 开启且侧边栏服务在场才移入扩展槽,注册后不再自动回退
|
|
975
|
+
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, wsModel)
|
|
976
|
+
let standalone = mountStandaloneBoard(wsModel)
|
|
977
|
+
let tabDisposer = null
|
|
978
|
+
let sidebarTabOn = false
|
|
979
|
+
// betterSidebar 服务软探测 + 晚注册追踪:出现/变化时按当前偏好重新决策
|
|
980
|
+
let currentSidebar = ctx.get('betterSidebar')
|
|
981
|
+
const applyPref = () => {
|
|
982
|
+
const wantTab = sidebarTabOn && currentSidebar !== undefined
|
|
983
|
+
if (wantTab && tabDisposer === null) {
|
|
984
|
+
standalone()
|
|
985
|
+
standalone = null
|
|
986
|
+
tabDisposer = attachBoardTab(currentSidebar)
|
|
987
|
+
} else if (!wantTab && tabDisposer !== null) {
|
|
988
|
+
tabDisposer()
|
|
989
|
+
tabDisposer = null
|
|
924
990
|
}
|
|
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
|
|
991
|
+
if (!wantTab && standalone === null) standalone = mountStandaloneBoard(wsModel)
|
|
935
992
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
993
|
+
const prefWatch = setInterval(async () => {
|
|
994
|
+
try {
|
|
995
|
+
const outcome = await request('GET', 'status')
|
|
996
|
+
const next = readSidebarTabPref(outcome)
|
|
997
|
+
if (next !== sidebarTabOn) {
|
|
998
|
+
sidebarTabOn = next
|
|
999
|
+
applyPref()
|
|
1000
|
+
}
|
|
1001
|
+
} catch { /* 轮询失败保形态不变,下轮重试 */ }
|
|
1002
|
+
}, 5 * 1000)
|
|
1003
|
+
ctx.effect(() => () => {
|
|
1004
|
+
clearInterval(prefWatch)
|
|
1005
|
+
if (standalone) standalone()
|
|
1006
|
+
if (tabDisposer) tabDisposer()
|
|
1007
|
+
}, 'cron-board mount arbitration')
|
|
1008
|
+
// 设置>插件页卡片:key 配对 ns,宿主按 describe 命名空间分发;effect 随 fiber 回收
|
|
1009
|
+
ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
|
|
1010
|
+
yield ctx.slots.register(
|
|
1011
|
+
{ name: 'settings.plugin.item', key: 'cron-board', label: '定时任务' },
|
|
1012
|
+
CronBoardPluginCard,
|
|
1013
|
+
)
|
|
1014
|
+
}), 'cron-board settings card')
|
|
940
1015
|
ctx.inject(['betterSidebar'], (bsCtx) => {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
attachBoardTab(bsCtx.betterSidebar)
|
|
1016
|
+
currentSidebar = bsCtx.betterSidebar
|
|
1017
|
+
applyPref()
|
|
944
1018
|
})
|
|
1019
|
+
applyPref()
|
|
945
1020
|
},
|
|
946
1021
|
}
|
|
947
1022
|
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-cron-board Host
|
|
1
|
+
// dsh-cron-board Host 半区:轻量级定时任务看板。环境变量集中管理 + 定时任务(cron)调度,
|
|
2
2
|
// 执行体两类:本地脚本(宿主进程内 spawn)与 dsh 会话任务(定时向 dsh 会话投递任务文本)。
|
|
3
3
|
// 模块分层:store(持久化)/ logger(日志)/ executor(执行链)/ scheduler(调度)/ api(路由)。
|
|
4
4
|
// timer 为软依赖(嵌套注入,缺失降级只影响自动调度);settings 缺省时读默认常量。
|
|
@@ -18,10 +18,10 @@ import { createStore } from './store.mjs'
|
|
|
18
18
|
|
|
19
19
|
export const name = 'dsh-cron-board'
|
|
20
20
|
|
|
21
|
-
// 会话任务依赖的 sessionController 由 dsh-web-app 的 session-controller
|
|
22
|
-
// (0.1.1-rc.2
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
25
|
export const inject = ['webServer']
|
|
26
26
|
|
|
27
27
|
// 数据目录 env 覆盖:测试注入临时目录(对齐 dsh-usage-panel 先例)
|
|
@@ -29,15 +29,18 @@ const DATA_DIR_ENV = 'DSH_CRON_BOARD_DATA_DIR'
|
|
|
29
29
|
const API_PREFIX = '/api/cron-board'
|
|
30
30
|
const SETTINGS_NS = 'cron-board'
|
|
31
31
|
const TIMER_UNAVAILABLE_REASON = '宿主定时服务不可用,自动调度已停用'
|
|
32
|
-
const SESSION_UNAVAILABLE_REASON = '宿主会话服务 sessionController 不可用(需 dsh-web-app 挂载 session-controller,0.1.1-rc.2 及更早版本缺失)
|
|
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
|
|
33
35
|
const DEFAULT_LOG_KEEP_PER_JOB = 200
|
|
34
36
|
|
|
35
|
-
// 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码
|
|
37
|
+
// 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码 / 侧边栏移入偏好
|
|
36
38
|
const SETTINGS_SCHEMA = schemastery.object({
|
|
37
39
|
tickSeconds: schemastery.number().min(MIN_TICK_MS / 1000).step(1).default(DEFAULT_TICK_MS / 1000),
|
|
38
40
|
maxConcurrent: schemastery.number().min(1).step(1).default(DEFAULT_MAX_CONCURRENT),
|
|
39
41
|
logKeepPerJob: schemastery.number().min(1).step(1).default(200),
|
|
40
42
|
maskEnvInPrompt: schemastery.boolean().default(false).description('会话任务投递文本中环境变量打码'),
|
|
43
|
+
sidebarTab: schemastery.boolean().default(false).description('看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面)'),
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
export function resolveDataDir(env = process.env) {
|
|
@@ -47,15 +50,11 @@ export function resolveDataDir(env = process.env) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function apply(ctx, config) {
|
|
50
|
-
// ctx.get 不受 inject 门控(cordis 明文契约:缺失返回 undefined),属性访问会被拦截抛错
|
|
51
|
-
const sessionController = ctx.get('sessionController')
|
|
52
|
-
if (!sessionController) {
|
|
53
|
-
if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + SESSION_UNAVAILABLE_REASON)
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
53
|
const dataDir = resolveDataDir()
|
|
57
54
|
// 装配惰性单例:首个请求 / timer 激活触发初始化,各组件只建一次
|
|
58
55
|
let runtimePromise = null
|
|
56
|
+
// 会话通道就绪状态:等待期 API 已可服务脚本任务,就绪/禁用后状态定格
|
|
57
|
+
const sessionState = { ready: false, disabled: false }
|
|
59
58
|
// 生命周期解绑引用:惰性装配完成前给安全空实现,装配后指向真实 scheduler
|
|
60
59
|
const schedulerRef = { current: { dispose() {} } }
|
|
61
60
|
const getRuntime = () => {
|
|
@@ -64,7 +63,7 @@ export function apply(ctx, config) {
|
|
|
64
63
|
const store = await createStore({ dir: dataDir })
|
|
65
64
|
const logger = createLogger({ rootDir: join(dataDir, 'logs') })
|
|
66
65
|
const sessionDriver = createSessionDriver({
|
|
67
|
-
|
|
66
|
+
getSessionController: () => sessionControllerRef.current,
|
|
68
67
|
agents: ctx.get('agents'),
|
|
69
68
|
sessionQuery: ctx.get('sessionQuery'),
|
|
70
69
|
})
|
|
@@ -86,6 +85,9 @@ export function apply(ctx, config) {
|
|
|
86
85
|
executor,
|
|
87
86
|
scheduler,
|
|
88
87
|
periodic,
|
|
88
|
+
sessionState,
|
|
89
|
+
readSidebarTab: () => readBoolean('sidebarTab', false),
|
|
90
|
+
updateUiSettings,
|
|
89
91
|
logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
|
|
90
92
|
})
|
|
91
93
|
return { api, scheduler }
|
|
@@ -95,6 +97,31 @@ export function apply(ctx, config) {
|
|
|
95
97
|
return runtimePromise
|
|
96
98
|
}
|
|
97
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
|
+
|
|
98
125
|
const periodic = { running: false, reason: null }
|
|
99
126
|
let armTick = null
|
|
100
127
|
let disposeTick = null
|
|
@@ -107,6 +134,13 @@ export function apply(ctx, config) {
|
|
|
107
134
|
const value = readSettingValue(name)
|
|
108
135
|
return typeof value === 'boolean' ? value : fallback
|
|
109
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
|
+
}
|
|
110
144
|
function readSettingValue(name) {
|
|
111
145
|
const settings = ctx.get('settings')
|
|
112
146
|
const scope = settings && settings.get ? settings.get(SETTINGS_NS) : undefined
|
|
@@ -153,20 +187,25 @@ export function apply(ctx, config) {
|
|
|
153
187
|
})
|
|
154
188
|
|
|
155
189
|
ctx.effect(
|
|
156
|
-
() =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
},
|
|
170
209
|
'cron-board api',
|
|
171
210
|
)
|
|
172
211
|
|
package/src/session-driver.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const REJECTED_MESSAGE = '会话输入投递被拒绝'
|
|
|
12
12
|
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
|
|
14
14
|
|
|
15
|
-
export function createSessionDriver({
|
|
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/test/env-expand.test.mjs
CHANGED
package/test/index.test.mjs
CHANGED
|
@@ -19,11 +19,12 @@ test('index:数据目录默认 ~/.dsh/cron-board,env 可覆盖', () => {
|
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
test('index:apply 注册 prefix 路由且请求走通(列表接口)', async () => {
|
|
22
|
-
// Given webServer 桩:收集注册路由(sessionController 经 get
|
|
22
|
+
// Given webServer 桩:收集注册路由(sessionController 经 get 桩供给,就绪等待同步完成)
|
|
23
23
|
const routes = new Map()
|
|
24
24
|
const sessionController = { create: async () => ({ sessionId: 's-x' }), prompt: async () => ({ accepted: true }) }
|
|
25
25
|
const ctx = {
|
|
26
|
-
effect(fn) {
|
|
26
|
+
effect(fn, label) {
|
|
27
|
+
if (label === 'cron-board session wait') { fn(); return }
|
|
27
28
|
fn()
|
|
28
29
|
},
|
|
29
30
|
inject() {},
|
|
@@ -83,10 +84,11 @@ function makeFullCtx({ timerAvailable = true } = {}) {
|
|
|
83
84
|
}
|
|
84
85
|
const intervals = []
|
|
85
86
|
const ctx = {
|
|
86
|
-
effect(fn) {
|
|
87
|
+
effect(fn, label) {
|
|
88
|
+
// 会话等待 effect 是真实定时器轮询形态,桩跳过直接执行体;controller 经 get 即可判
|
|
89
|
+
if (label === 'cron-board session wait') { fn(); return }
|
|
87
90
|
fn()
|
|
88
91
|
},
|
|
89
|
-
sessionController,
|
|
90
92
|
get(name) {
|
|
91
93
|
if (name === 'settings') return settingsService
|
|
92
94
|
if (name === 'sessionController') return sessionController
|
|
@@ -117,21 +119,22 @@ function makeFullCtx({ timerAvailable = true } = {}) {
|
|
|
117
119
|
return { ctx, routes, registered, intervals, settingsService, sessions }
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
test('index:inject 仅声明 webServer(sessionController
|
|
121
|
-
// Given sessionController
|
|
122
|
-
// pending
|
|
123
|
-
// Then 静态锁定 inject
|
|
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 内轮询等待 + 超时禁用承担
|
|
124
126
|
assert.deepEqual(mod.inject, ['webServer'])
|
|
125
127
|
})
|
|
126
128
|
|
|
127
|
-
test('index:sessionController
|
|
128
|
-
// Given 无 sessionController 的宿主上下文
|
|
129
|
+
test('index:sessionController 恒缺失时超时干净禁用会话通道(脚本任务不受影响)', async () => {
|
|
130
|
+
// Given 无 sessionController 的宿主上下文(等价 0.1.1-rc.2)
|
|
129
131
|
const routes = new Map()
|
|
130
|
-
const registered = []
|
|
131
132
|
const warnings = []
|
|
133
|
+
const effects = []
|
|
132
134
|
const ctx = {
|
|
133
|
-
effect(fn) {
|
|
134
|
-
|
|
135
|
+
effect(fn, label) {
|
|
136
|
+
if (label === 'cron-board session wait') effects.push(fn)
|
|
137
|
+
else fn()
|
|
135
138
|
},
|
|
136
139
|
inject() {},
|
|
137
140
|
logger: { warn: (line) => warnings.push(line) },
|
|
@@ -143,13 +146,13 @@ test('index:sessionController 缺失时干净禁用(不注册路由与设置)',
|
|
|
143
146
|
},
|
|
144
147
|
},
|
|
145
148
|
}
|
|
146
|
-
// When apply
|
|
149
|
+
// When apply(路由同步注册,会话等待 effect 挂起)
|
|
147
150
|
apply(ctx)
|
|
148
|
-
// Then
|
|
149
|
-
assert.equal(routes.size,
|
|
150
|
-
assert.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// Then 路由已注册(脚本任务可用),等待 effect 在列
|
|
152
|
+
assert.equal(routes.size, 1)
|
|
153
|
+
assert.equal(effects.length, 1)
|
|
154
|
+
// When 超时轮询终结(缩短等待:直接跑完等待窗)
|
|
155
|
+
effects[0]()
|
|
153
156
|
})
|
|
154
157
|
|
|
155
158
|
test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期 tick', async () => {
|
package/test/routes.test.mjs
CHANGED
|
@@ -273,11 +273,47 @@ test('status 路由:报告调度器与 timer 可用性', async (t) => {
|
|
|
273
273
|
})
|
|
274
274
|
// When GET /status
|
|
275
275
|
const res = await call(api, 'GET', '/api/cron-board/status')
|
|
276
|
-
// Then
|
|
276
|
+
// Then 状态齐备;ui 偏好缺省为关(默认主界面)
|
|
277
277
|
assert.equal(res.status, 200)
|
|
278
278
|
assert.equal(res.payload.timerRunning, true)
|
|
279
279
|
assert.deepEqual(res.payload.scheduler, { active: 1, queued: 2 })
|
|
280
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)
|
|
281
317
|
})
|
|
282
318
|
|
|
283
319
|
test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', async (t) => {
|
|
@@ -330,6 +366,28 @@ test('jobs 路由:会话任务字段校验与默认值', async (t) => {
|
|
|
330
366
|
assert.equal(badMiss.status, 400)
|
|
331
367
|
})
|
|
332
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
|
+
|
|
333
391
|
test('cron preview 路由:人话摘要与下三次触发', async (t) => {
|
|
334
392
|
const { api } = await makeApi(t)
|
|
335
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
|
-
|
|
47
|
+
getSessionController: () => stubs.sessionController,
|
|
48
48
|
agents: stubs.agents,
|
|
49
49
|
sessionQuery: stubs.sessionQuery,
|
|
50
50
|
pollIntervalMs: 5,
|
package/test/store.test.mjs
CHANGED
|
@@ -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
|
})
|
|
@@ -41,6 +41,18 @@ test('checked/focus-visible/disabled 三态均以 checkbox 锚定', () => {
|
|
|
41
41
|
assert.match(source, /\.cb-switch input\[type="checkbox"\]:disabled \+/)
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('插件页卡片为官方 PluginCard 形制:li 卡壳 + 头部折叠 + 令牌同源', () => {
|
|
45
|
+
// Given 官方插件卡片 = ul.cards > li.PluginCard(边框卡壳 + 名称/描述头部 + chevron 折叠)
|
|
46
|
+
// When 本插件卡片注册进同一列表(slot 契约:外观归插件自持,官方壳未导出)
|
|
47
|
+
// Then 静态锁定 cb-pc 镜像形制,防止退化回裸 div 平铺
|
|
48
|
+
assert.match(source, /h\('li', \{ className: 'cb-pc/, '卡片根元素必须是 li(官方列表 ul.cards 的合法子元素)')
|
|
49
|
+
assert.match(source, /\.cb-pc\{border:\.5px solid var\(--dsw-alias-border-l4\)/, '卡壳边框/底色令牌必须与官方 PluginCard 同源')
|
|
50
|
+
assert.match(source, /h\('button', \{ type: 'button', className: 'cb-pc__head'/, '缺少官方形制的头部按钮')
|
|
51
|
+
assert.match(source, /aria-expanded/, '头部必须可折叠并暴露 aria-expanded')
|
|
52
|
+
assert.match(source, /\.cb-pc__hint\{[^}]*var\(--dsw-alias-label-tertiary\)/, '提示文本令牌必须与官方 hint 同源')
|
|
53
|
+
assert.doesNotMatch(source, /'cb-settings'/, '旧裸 div 平铺形态已移除')
|
|
54
|
+
})
|
|
55
|
+
|
|
44
56
|
test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
|
|
45
57
|
// IIFE 书挡内允许任意声明;守卫确认整文件被书挡包裹
|
|
46
58
|
const trimmed = source.trim()
|
|
@@ -48,9 +60,10 @@ test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
|
|
|
48
60
|
assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
|
|
49
61
|
})
|
|
50
62
|
|
|
51
|
-
test('client.js 主页面双形态挂载契约(
|
|
52
|
-
// Given
|
|
53
|
-
//
|
|
63
|
+
test('client.js 主页面双形态挂载契约(默认主界面;设置开关手动移入侧边栏;无自动回退)', () => {
|
|
64
|
+
// Given 用户要求:默认永远主界面;检测到 better-sidebar 提供设置项,由用户手动移入;
|
|
65
|
+
// 移入后页签关闭/禁用不再自动返回主界面(挂载权完全交给用户与侧边栏)
|
|
66
|
+
// Then 静态锁定:模块注册形态、服务软探测、tab 单实例、主界面容器与互斥事件、偏好驱动仲裁
|
|
54
67
|
assert.match(source, /window\.__ModuleLoader__\.load\(\{ id: '@mzzsfy\/dsh-cron-board', factory \}\)/)
|
|
55
68
|
assert.match(source, /ctx\.get\('betterSidebar'\)/)
|
|
56
69
|
assert.match(source, /single: true/)
|
|
@@ -58,6 +71,17 @@ test('client.js 主页面双形态挂载契约(better-sidebar 优先/主界面
|
|
|
58
71
|
assert.match(source, /data-cb-board-active/)
|
|
59
72
|
assert.match(source, /dsh-panel-activate/)
|
|
60
73
|
assert.match(source, /function mountStandaloneBoard/)
|
|
61
|
-
//
|
|
62
|
-
assert.
|
|
74
|
+
// 设置>插件页卡片(settings.plugin.item,key 配对 ns);better-sidebar 在场可切换,否则禁用仅展示
|
|
75
|
+
assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
|
|
76
|
+
assert.match(source, /key: 'cron-board', label: '定时任务'/)
|
|
77
|
+
assert.match(source, /function CronBoardPluginCard/)
|
|
78
|
+
assert.match(source, /'ui-settings'/)
|
|
79
|
+
assert.match(source, /sidebarReady/)
|
|
80
|
+
// 挂载仲裁:apply 无条件先落主界面形态;偏好(status.ui.sidebarTab)驱动接入/退出
|
|
81
|
+
assert.match(source, /let standalone = mountStandaloneBoard\(wsModel\)/)
|
|
82
|
+
assert.match(source, /applyPref\(\)/)
|
|
83
|
+
assert.match(source, /ui\.sidebarTab/)
|
|
84
|
+
// 不再自动回退:attach 后不订阅页签状态,无 openTabs/subscribeState 联动
|
|
85
|
+
assert.doesNotMatch(source, /subscribeState/)
|
|
86
|
+
assert.doesNotMatch(source, /openTabs/)
|
|
63
87
|
})
|