@mzzsfy/dsh-cron-board 0.2.0 → 0.3.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 +7 -7
- package/package.json +1 -1
- package/src/api.mjs +44 -18
- package/src/client.js +345 -115
- package/src/executor.mjs +10 -1
- package/src/index.js +7 -1
- package/src/schedule-builder.mjs +73 -0
- package/src/scheduler.mjs +3 -39
- package/src/session-driver.mjs +63 -27
- package/test/executor.test.mjs +44 -0
- package/test/index.test.mjs +22 -3
- package/test/parity.test.mjs +1 -1
- package/test/routes.test.mjs +65 -25
- package/test/schedule-builder.test.mjs +123 -0
- package/test/scheduler.test.mjs +0 -131
- package/test/session-driver.test.mjs +167 -31
- package/test/switch-guard.test.mjs +12 -0
- package/src/session-window.mjs +0 -37
package/src/executor.mjs
CHANGED
|
@@ -67,7 +67,12 @@ export function createExecutor({ store, logger, runner, sessionRunner, maxExpans
|
|
|
67
67
|
await store.runs.update(record.runId, patch)
|
|
68
68
|
// 任务卡状态回填:看板状态点/耗时直接读任务行(手动与 cron 同源更新)
|
|
69
69
|
const jobPatch = { lastStatus: outcome.status, lastDurationMs: patch.durationMs }
|
|
70
|
-
if (outcome.pinnedNewId !== undefined)
|
|
70
|
+
if (outcome.pinnedNewId !== undefined) {
|
|
71
|
+
// pinned 绑定必须落 session 子对象(driver 读 job.session.pinnedSessionId),
|
|
72
|
+
// 浅合并保留 mode 等其余字段;顶层孤立字段以 undefined 覆盖,落盘时自然消失(历史数据自愈)
|
|
73
|
+
jobPatch.session = { ...job.session, pinnedSessionId: outcome.pinnedNewId }
|
|
74
|
+
jobPatch.pinnedSessionId = undefined
|
|
75
|
+
}
|
|
71
76
|
await store.jobs.update(job.id, jobPatch)
|
|
72
77
|
} catch (error) {
|
|
73
78
|
// runner 拒绝(会话服务异常等)也必须有终态,否则运行记录永久停留 running;
|
|
@@ -124,6 +129,10 @@ export function createExecutor({ store, logger, runner, sessionRunner, maxExpans
|
|
|
124
129
|
records.push(record)
|
|
125
130
|
queue.push({ job, record, env: combinations[i], runnerFor })
|
|
126
131
|
}
|
|
132
|
+
// 单次任务入队即禁用:先占运行再落禁用,封死 cron 连续 tick 的重复触发窗口
|
|
133
|
+
if (job.runOnce === true) {
|
|
134
|
+
await store.jobs.update(job.id, { enabled: false })
|
|
135
|
+
}
|
|
127
136
|
pump()
|
|
128
137
|
return records.map((record) => record.runId)
|
|
129
138
|
},
|
package/src/index.js
CHANGED
|
@@ -64,6 +64,10 @@ export function apply(ctx, config) {
|
|
|
64
64
|
const logger = createLogger({ rootDir: join(dataDir, 'logs') })
|
|
65
65
|
const sessionDriver = createSessionDriver({
|
|
66
66
|
getSessionController: () => sessionControllerRef.current,
|
|
67
|
+
// 分组解析为可选增强:registry 缺失(旧宿主)时 driver 干净降级为 cwd-only
|
|
68
|
+
getWorkspaceRegistry: () => ctx.get('workspaceRegistry'),
|
|
69
|
+
// 执行预设目录为可选增强:服务缺失时任务不带 preset,由宿主解析默认
|
|
70
|
+
getAgentPresets: () => ctx.get('agentPresets'),
|
|
67
71
|
agents: ctx.get('agents'),
|
|
68
72
|
sessionQuery: ctx.get('sessionQuery'),
|
|
69
73
|
})
|
|
@@ -88,6 +92,7 @@ export function apply(ctx, config) {
|
|
|
88
92
|
sessionState,
|
|
89
93
|
readSidebarTab: () => readBoolean('sidebarTab', false),
|
|
90
94
|
updateUiSettings,
|
|
95
|
+
getAgentPresets: () => ctx.get('agentPresets'),
|
|
91
96
|
logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
|
|
92
97
|
})
|
|
93
98
|
return { api, scheduler }
|
|
@@ -210,5 +215,6 @@ export function apply(ctx, config) {
|
|
|
210
215
|
)
|
|
211
216
|
|
|
212
217
|
// defer 挂起定时器随插件生命周期回收,不遗留生命周期外触发
|
|
213
|
-
|
|
218
|
+
// 生命周期钩子:scheduler 无挂起资源时 dispose 可缺省,可选调用保持约定统一
|
|
219
|
+
ctx.effect(() => () => schedulerRef.current.dispose?.(), 'cron-board scheduler lifecycle')
|
|
214
220
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 调度构建器纯函数:表单结构化状态(freq + 上下文)与 cron 表达式互转。
|
|
2
|
+
// 仅覆盖构建器可表达的形态,反解不出即 custom(表达式原样手输);触发点计算仍归 croner(cron.mjs)。
|
|
3
|
+
|
|
4
|
+
export const FREQS = ['daily', 'weekly', 'weekdays', 'interval', 'custom']
|
|
5
|
+
|
|
6
|
+
export const WEEKDAY_ORDER = ['1', '2', '3', '4', '5', '6', '0']
|
|
7
|
+
export const WEEKDAY_LABELS = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六' }
|
|
8
|
+
|
|
9
|
+
export const MINUTE_MAX = 59
|
|
10
|
+
export const HOUR_MAX = 23
|
|
11
|
+
export const INTERVAL_MIN_MINUTES = 1
|
|
12
|
+
|
|
13
|
+
// 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置)
|
|
14
|
+
export function createScheduleState(expression) {
|
|
15
|
+
return {
|
|
16
|
+
freq: 'daily',
|
|
17
|
+
hour: 9,
|
|
18
|
+
minute: 0,
|
|
19
|
+
weekdays: ['1'],
|
|
20
|
+
intervalMinutes: 30,
|
|
21
|
+
expression,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clampInt = (raw, min, max, fallback) => {
|
|
26
|
+
const value = Number(raw)
|
|
27
|
+
if (!Number.isInteger(value) || value < min || value > max) return fallback
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜
|
|
32
|
+
export function parseSchedule(expression) {
|
|
33
|
+
const raw = String(expression).trim()
|
|
34
|
+
const parts = raw.split(/\s+/)
|
|
35
|
+
const state = createScheduleState(raw)
|
|
36
|
+
if (parts.length !== 5) { state.freq = 'custom'; return state }
|
|
37
|
+
const [minute, hour, day, month, weekday] = parts
|
|
38
|
+
if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
|
|
39
|
+
if (minute === '*' && hour === '*') { state.freq = 'custom'; return state }
|
|
40
|
+
if (/^\*\/\d+$/.test(minute) && hour === '*') {
|
|
41
|
+
state.freq = 'interval'
|
|
42
|
+
state.intervalMinutes = clampInt(minute.slice(2), INTERVAL_MIN_MINUTES, MINUTE_MAX, 0)
|
|
43
|
+
if (state.intervalMinutes === 0) state.freq = 'custom'
|
|
44
|
+
return state
|
|
45
|
+
}
|
|
46
|
+
const hourNum = clampInt(hour, 0, HOUR_MAX, NaN)
|
|
47
|
+
const minuteNum = clampInt(minute, 0, MINUTE_MAX, NaN)
|
|
48
|
+
if (!Number.isInteger(hourNum) || !Number.isInteger(minuteNum)) { state.freq = 'custom'; return state }
|
|
49
|
+
state.hour = hourNum
|
|
50
|
+
state.minute = minuteNum
|
|
51
|
+
if (weekday === '*') { state.freq = 'daily'; return state }
|
|
52
|
+
if (weekday === '1-5') { state.freq = 'weekdays'; return state }
|
|
53
|
+
const days = weekday.split(',')
|
|
54
|
+
if (days.every((d) => /^\d$/.test(d) && d in WEEKDAY_LABELS)) {
|
|
55
|
+
state.freq = 'weekly'
|
|
56
|
+
state.weekdays = WEEKDAY_ORDER.filter((d) => days.includes(d))
|
|
57
|
+
return state
|
|
58
|
+
}
|
|
59
|
+
state.freq = 'custom'
|
|
60
|
+
return state
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 构建器状态 → cron;custom 直接返回手工表达式
|
|
64
|
+
export function buildSchedule(state) {
|
|
65
|
+
if (state.freq === 'custom') return String(state.expression).trim()
|
|
66
|
+
const minute = String(state.minute)
|
|
67
|
+
const hour = String(state.hour)
|
|
68
|
+
if (state.freq === 'daily') return minute + ' ' + hour + ' * * *'
|
|
69
|
+
if (state.freq === 'weekdays') return minute + ' ' + hour + ' * * 1-5'
|
|
70
|
+
if (state.freq === 'weekly') return minute + ' ' + hour + ' * * ' + [...state.weekdays].sort((a, b) => Number(a) - Number(b)).join(',')
|
|
71
|
+
if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
|
|
72
|
+
return String(state.expression).trim()
|
|
73
|
+
}
|
package/src/scheduler.mjs
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
// scheduler:单一调度循环。tick
|
|
2
|
-
//
|
|
1
|
+
// scheduler:单一调度循环。tick 固定三步——扫描到期、先推进 nextRunAt 再执行、
|
|
2
|
+
// 经执行链闸门入队,收尾由执行链负责。misfire 语义:错过触发点超过一个
|
|
3
3
|
// tick 周期即视为停机期间错过,不补跑,记 skipped 并推进(轻量取舍,设计 §4.1)。
|
|
4
4
|
|
|
5
5
|
import { nextRunAtOf } from './cron.mjs'
|
|
6
|
-
import { hasWindow, inWindow, nextWindowStart } from './session-window.mjs'
|
|
7
6
|
|
|
8
7
|
const SKIPPED_MISFIRE_MESSAGE = '停机期间错过'
|
|
9
|
-
const SKIPPED_WINDOW_MESSAGE = '窗口外跳过'
|
|
10
8
|
const INTERRUPTED_MESSAGE = 'dsh 重启中断'
|
|
11
9
|
|
|
12
|
-
export function createScheduler({ store, executor, readTickMs, now = () => Date.now()
|
|
10
|
+
export function createScheduler({ store, executor, readTickMs, now = () => Date.now() }) {
|
|
13
11
|
// 重入闸门:上一 tick 未完成(store 写链排队/慢盘)时跳过本轮,防同一触发点双触发
|
|
14
12
|
let ticking = false
|
|
15
|
-
// defer 挂起句柄登记:dispose 统一清除,不遗留生命周期外触发
|
|
16
|
-
const deferredCancels = new Set()
|
|
17
13
|
|
|
18
14
|
// 重启孤儿收尾:上次进程遗留的 queued/running 统一标 interrupted,不误标失败
|
|
19
15
|
async function recover() {
|
|
@@ -23,21 +19,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
23
19
|
}
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
// defer 到点回调:按 id 重读任务,已删/已停/会话形态变化一律放弃(不吃旧快照)
|
|
27
|
-
function armDeferred(jobId, at) {
|
|
28
|
-
const handle = scheduleAt(at, () => {
|
|
29
|
-
deferredCancels.delete(cancel)
|
|
30
|
-
void (async () => {
|
|
31
|
-
const job = store.jobs.get(jobId)
|
|
32
|
-
if (!job || !job.enabled || job.kind !== 'session' || !hasWindow(job.session)) return
|
|
33
|
-
if (inWindow(job.session, new Date(now()))) await executor.dispatch(job, 'cron')
|
|
34
|
-
})().catch(() => {})
|
|
35
|
-
})
|
|
36
|
-
// 句柄取消形态归一:函数直接调,定时器对象走 clearTimeout
|
|
37
|
-
const cancel = typeof handle === 'function' ? handle : () => clearTimeout(handle)
|
|
38
|
-
deferredCancels.add(cancel)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
22
|
async function tick() {
|
|
42
23
|
if (ticking) return
|
|
43
24
|
ticking = true
|
|
@@ -58,15 +39,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
58
39
|
await store.runs.create({ jobId: job.id, trigger: 'cron', status: 'skipped', message: SKIPPED_MISFIRE_MESSAGE })
|
|
59
40
|
continue
|
|
60
41
|
}
|
|
61
|
-
// 会话任务窗口:窗口外按 onMiss 跳过或顺延至窗口起点(defer 挂起不持久化,重启按 skip)
|
|
62
|
-
if (job.kind === 'session' && hasWindow(job.session) && !inWindow(job.session, new Date(nowMs))) {
|
|
63
|
-
if (job.session.onMiss === 'defer') {
|
|
64
|
-
armDeferred(job.id, nextWindowStart(job.session, new Date(nowMs)).getTime())
|
|
65
|
-
} else {
|
|
66
|
-
await store.runs.create({ jobId: job.id, trigger: 'cron', status: 'skipped', message: SKIPPED_WINDOW_MESSAGE })
|
|
67
|
-
}
|
|
68
|
-
continue
|
|
69
|
-
}
|
|
70
42
|
await executor.dispatch(job, 'cron')
|
|
71
43
|
}
|
|
72
44
|
} finally {
|
|
@@ -74,13 +46,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
74
46
|
}
|
|
75
47
|
}
|
|
76
48
|
|
|
77
|
-
function dispose() {
|
|
78
|
-
for (const cancel of deferredCancels) {
|
|
79
|
-
try { cancel() } catch { /* 句柄已自行失效即无清理需求 */ }
|
|
80
|
-
}
|
|
81
|
-
deferredCancels.clear()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
49
|
// 下次触发预览:启用任务中最小的 nextRunAt(面板状态展示用)
|
|
85
50
|
function nextRunAt() {
|
|
86
51
|
const dueList = store.jobs
|
|
@@ -92,7 +57,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
92
57
|
return {
|
|
93
58
|
recover,
|
|
94
59
|
tick,
|
|
95
|
-
dispose,
|
|
96
60
|
status: () => executor.status(),
|
|
97
61
|
nextRunAt,
|
|
98
62
|
}
|
package/src/session-driver.mjs
CHANGED
|
@@ -1,25 +1,72 @@
|
|
|
1
1
|
// session-driver:HostApiDriver 会话编排(设计 §4.3,生产实现唯一)。
|
|
2
|
-
// 发起 = 新建/复用会话 + queue
|
|
3
|
-
// pinned
|
|
2
|
+
// 发起 = 分组解析 + 新建/复用会话 + queue 投递;投递成功即终态(crontab 语义:看板只管触发,
|
|
3
|
+
// 执行状态归会话本身);pinned 忙跳过、未绑定/丢失自愈(三态区分)、发起成功即写 RunRecord.sessionId。
|
|
4
|
+
// 变量折叠与掩码由 env 文本构造承担。
|
|
4
5
|
|
|
5
6
|
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { realpath } from 'node:fs/promises'
|
|
6
8
|
|
|
7
9
|
import { buildPromptText } from './env-text.mjs'
|
|
8
10
|
|
|
9
11
|
const BUSY_MESSAGE = '上一轮仍在进行'
|
|
12
|
+
const REBOUND_MESSAGE = '首次运行已创建并绑定会话'
|
|
10
13
|
const RECREATED_MESSAGE = '原会话丢失已重建'
|
|
11
14
|
const REJECTED_MESSAGE = '会话输入投递被拒绝'
|
|
15
|
+
const DELIVERED_MESSAGE = '已投递会话'
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
// 宿主入口(prompt 准入 / listSessions 读取)逐版本形态有差异:prompt 各版本均非可选链校验
|
|
18
|
+
// signal,listSessions 各版本为可选链;进程内调用无取消来源,统一传永不中止的信号,
|
|
19
|
+
// 宿主仅在入口读一次不保留,共享单例安全,宿主将来收紧为非可选链亦无需再改
|
|
20
|
+
const ADMISSION_SIGNAL = new AbortController().signal
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
// pinned 自愈三态:未绑定过(首跑)/已绑定但会话丢失;两者都回写新绑定,仅文案不同
|
|
23
|
+
function recreatedMessage(recreated) {
|
|
24
|
+
return recreated === 'unbound' ? REBOUND_MESSAGE : RECREATED_MESSAGE
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 分组解析(dsh-im workspaceId 同构):按任务 workdir 找已有分组,未命中建同名分组;
|
|
28
|
+
// registry 缺失或 workdir 不存在时降级为无分组,由调用方回落 cwd 路径
|
|
29
|
+
async function resolveWorkspaceId(registry, workdir) {
|
|
30
|
+
if (!registry || typeof registry.resolveByPath !== 'function' || typeof registry.createCanonical !== 'function') {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
const existing = await registry.resolveByPath(workdir)
|
|
34
|
+
if (existing) return existing.id
|
|
35
|
+
const canonical = await realpath(workdir)
|
|
36
|
+
const created = await registry.createCanonical(canonical)
|
|
37
|
+
return created.id
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSessionDriver({ getSessionController, getWorkspaceRegistry, agents, sessionQuery }) {
|
|
16
41
|
// pinned 会话存在性:持久层 headers 比对(冷会话不在 agents 注册表)
|
|
17
42
|
async function pinnedExists(sessionId) {
|
|
18
43
|
if (!sessionQuery || typeof sessionQuery.listSessions !== 'function') return Boolean(agents.get(sessionId))
|
|
19
|
-
const records = await sessionQuery.listSessions()
|
|
44
|
+
const records = await sessionQuery.listSessions(ADMISSION_SIGNAL)
|
|
20
45
|
return records.some((record) => record && record.id === sessionId)
|
|
21
46
|
}
|
|
22
47
|
|
|
48
|
+
// pinned 可用性:已删除(不在持久层)或已归档(从所有分组视图隐藏)均视为丢失,触发重建
|
|
49
|
+
async function pinnedUsable(sessionId) {
|
|
50
|
+
const registry = getWorkspaceRegistry ? getWorkspaceRegistry() : undefined
|
|
51
|
+
const archivedIds = registry?.archivedSessionIds
|
|
52
|
+
if (Array.isArray(archivedIds) && archivedIds.includes(sessionId)) return false
|
|
53
|
+
return pinnedExists(sessionId)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 建会话请求:分组按 workdir 挂载(dsh-im 同构);执行预设非空才带字段,
|
|
57
|
+
// 缺省时省略 agentPreset 由宿主解析当前默认
|
|
58
|
+
async function createSession(sessionController, workdir, agentPreset) {
|
|
59
|
+
const payload = {}
|
|
60
|
+
if (workdir) {
|
|
61
|
+
const registry = getWorkspaceRegistry ? getWorkspaceRegistry() : undefined
|
|
62
|
+
const workspaceId = await resolveWorkspaceId(registry, workdir).catch(() => undefined)
|
|
63
|
+
if (workspaceId) payload.workspaceId = workspaceId
|
|
64
|
+
else payload.cwd = workdir
|
|
65
|
+
}
|
|
66
|
+
if (agentPreset) payload.agentPreset = agentPreset
|
|
67
|
+
return sessionController.create(Object.keys(payload).length > 0 ? payload : undefined)
|
|
68
|
+
}
|
|
69
|
+
|
|
23
70
|
async function run({ job, env, mask, updateRecord }) {
|
|
24
71
|
// 控制器运行时动态解析:装配可能先于宿主挂载 sessionController(异步就绪等待中)
|
|
25
72
|
const sessionController = getSessionController ? getSessionController() : undefined
|
|
@@ -30,18 +77,20 @@ export function createSessionDriver({ getSessionController, agents, sessionQuery
|
|
|
30
77
|
let recreated = false
|
|
31
78
|
|
|
32
79
|
if (isPinned) {
|
|
33
|
-
// pinned 串行:上轮未完成直接跳过(
|
|
80
|
+
// pinned 串行:上轮未完成直接跳过(不排队,agents 实时状态判定)
|
|
34
81
|
const entry = agents ? agents.get(sessionId) : undefined
|
|
35
82
|
if (entry && entry.status === 'running') {
|
|
36
83
|
return { status: 'skipped', message: BUSY_MESSAGE }
|
|
37
84
|
}
|
|
38
|
-
if (!sessionId
|
|
39
|
-
recreated =
|
|
85
|
+
if (!sessionId) {
|
|
86
|
+
recreated = 'unbound'
|
|
87
|
+
} else if (!(await pinnedUsable(sessionId))) {
|
|
88
|
+
recreated = 'lost'
|
|
40
89
|
}
|
|
41
90
|
}
|
|
42
91
|
|
|
43
92
|
if (recreated || !isPinned) {
|
|
44
|
-
const created = await sessionController
|
|
93
|
+
const created = await createSession(sessionController, job.workdir, session.agentPreset)
|
|
45
94
|
sessionId = created.sessionId
|
|
46
95
|
}
|
|
47
96
|
|
|
@@ -50,32 +99,19 @@ export function createSessionDriver({ getSessionController, agents, sessionQuery
|
|
|
50
99
|
sessionId,
|
|
51
100
|
mode: 'queue',
|
|
52
101
|
content: [{ type: 'text', text: buildPromptText({ prompt: job.prompt || '', env, mask }) }],
|
|
53
|
-
})
|
|
102
|
+
}, ADMISSION_SIGNAL)
|
|
54
103
|
if (!submission || submission.accepted !== true) {
|
|
55
104
|
return { status: 'fail', message: REJECTED_MESSAGE, sessionId }
|
|
56
105
|
}
|
|
57
106
|
if (typeof updateRecord === 'function') {
|
|
58
107
|
await updateRecord({ sessionId })
|
|
59
108
|
}
|
|
60
|
-
|
|
61
|
-
// 完成等待:状态脱离 running 即结算,timeoutMs 到点收尾
|
|
62
|
-
const timeoutMs = Number.isInteger(job.timeoutMs) && job.timeoutMs > 0 ? job.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
63
|
-
const deadline = now() + timeoutMs
|
|
64
|
-
while (now() < deadline) {
|
|
65
|
-
const entry = agents ? agents.get(sessionId) : undefined
|
|
66
|
-
if (!entry || entry.status !== 'running') {
|
|
67
|
-
return {
|
|
68
|
-
status: 'success',
|
|
69
|
-
sessionId,
|
|
70
|
-
...(recreated ? { pinnedNewId: sessionId, message: RECREATED_MESSAGE } : {}),
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
await sleep(pollIntervalMs)
|
|
74
|
-
}
|
|
109
|
+
// 投递即终态:运行记录反映触发结果,执行进展在会话内查看
|
|
75
110
|
return {
|
|
76
|
-
status: '
|
|
111
|
+
status: 'success',
|
|
77
112
|
sessionId,
|
|
78
|
-
|
|
113
|
+
message: recreated ? recreatedMessage(recreated) : DELIVERED_MESSAGE,
|
|
114
|
+
...(recreated ? { pinnedNewId: sessionId } : {}),
|
|
79
115
|
}
|
|
80
116
|
}
|
|
81
117
|
|
package/test/executor.test.mjs
CHANGED
|
@@ -55,6 +55,50 @@ test('executor:正常路径终态 success 且任务卡回填', async (t) => {
|
|
|
55
55
|
assert.equal(store.jobs.get('j1').lastStatus, 'success')
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
test('executor:runOnce 任务入队即禁用', async (t) => {
|
|
59
|
+
const { store, executor } = await makeExecutor(t)
|
|
60
|
+
// Given 启用中的 runOnce 任务
|
|
61
|
+
await store.jobs.create({ ...JOB_BASE, id: 'j-once', runOnce: true })
|
|
62
|
+
// When dispatch(入队,不等终态)
|
|
63
|
+
await executor.dispatch({ ...JOB_BASE, id: 'j-once', runOnce: true }, 'manual')
|
|
64
|
+
// Then 任务立即禁用(封死 cron 连续触发的重复运行窗口)
|
|
65
|
+
assert.equal(store.jobs.get('j-once').enabled, false)
|
|
66
|
+
// 收尾:放行执行到终态,避免悬挂写与临时目录清理赛跑
|
|
67
|
+
await dispatchAndSettle(executor, store, 'j-once')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('executor:非 runOnce 任务运行后保持启用', async (t) => {
|
|
71
|
+
const { store, executor } = await makeExecutor(t)
|
|
72
|
+
await store.jobs.create({ ...JOB_BASE, id: 'j-keep' })
|
|
73
|
+
await dispatchAndSettle(executor, store, 'j-keep')
|
|
74
|
+
assert.equal(store.jobs.get('j-keep').enabled, true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('executor:pinned 自愈回写 session.pinnedSessionId 且清除顶层孤立字段', async (t) => {
|
|
78
|
+
const { store, executor } = await makeExecutor(t)
|
|
79
|
+
// Given pinned 任务(session 子对象承载绑定),runner 返回 pinnedNewId 模拟自愈
|
|
80
|
+
const executorWithPinned = createExecutor({
|
|
81
|
+
store: await (async () => {
|
|
82
|
+
await store.jobs.create({ ...JOB_BASE, kind: 'session', session: { mode: 'pinned', pinnedSessionId: '' }, id: 'j-pin' })
|
|
83
|
+
return store
|
|
84
|
+
})(),
|
|
85
|
+
logger: createLogger({ rootDir: join(tmpdir(), 'cron-board-exec-pin-logs') }),
|
|
86
|
+
sessionRunner: { run: async () => ({ status: 'success', sessionId: 's-new', pinnedNewId: 's-new', message: '首次运行已创建并绑定会话' }) },
|
|
87
|
+
readMaxConcurrent: () => 2,
|
|
88
|
+
})
|
|
89
|
+
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 子对象,顶层无孤立残留
|
|
96
|
+
const job = store.jobs.get('j-pin')
|
|
97
|
+
assert.equal(job.session.pinnedSessionId, 's-new')
|
|
98
|
+
assert.equal(job.pinnedSessionId, undefined)
|
|
99
|
+
assert.equal(store.runs.get(runId).message, '首次运行已创建并绑定会话')
|
|
100
|
+
})
|
|
101
|
+
|
|
58
102
|
test('executor:runner 拒绝时补写 fail 终态', async (t) => {
|
|
59
103
|
const dir = await mkdtemp(join(tmpdir(), 'cron-board-exec-'))
|
|
60
104
|
t.after(() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
package/test/index.test.mjs
CHANGED
|
@@ -21,7 +21,14 @@ test('index:数据目录默认 ~/.dsh/cron-board,env 可覆盖', () => {
|
|
|
21
21
|
test('index:apply 注册 prefix 路由且请求走通(列表接口)', async () => {
|
|
22
22
|
// Given webServer 桩:收集注册路由(sessionController 经 get 桩供给,就绪等待同步完成)
|
|
23
23
|
const routes = new Map()
|
|
24
|
-
const sessionController = {
|
|
24
|
+
const sessionController = {
|
|
25
|
+
create: async () => ({ sessionId: 's-x' }),
|
|
26
|
+
// 镜像宿主 facade 契约:prompt 准入首行非可选链校验 signal
|
|
27
|
+
prompt: async (args, signal) => {
|
|
28
|
+
signal.throwIfAborted()
|
|
29
|
+
return { accepted: true }
|
|
30
|
+
},
|
|
31
|
+
}
|
|
25
32
|
const ctx = {
|
|
26
33
|
effect(fn, label) {
|
|
27
34
|
if (label === 'cron-board session wait') { fn(); return }
|
|
@@ -74,7 +81,9 @@ function makeFullCtx({ timerAvailable = true } = {}) {
|
|
|
74
81
|
sessions.set(sessionId, { id: sessionId, status: 'running' })
|
|
75
82
|
return { sessionId }
|
|
76
83
|
},
|
|
77
|
-
async prompt(args) {
|
|
84
|
+
async prompt(args, signal) {
|
|
85
|
+
// 镜像宿主 facade 契约:prompt 准入首行非可选链校验 signal
|
|
86
|
+
signal.throwIfAborted()
|
|
78
87
|
queueMicrotask(() => {
|
|
79
88
|
const entry = sessions.get(args.sessionId)
|
|
80
89
|
if (entry) entry.status = 'idle'
|
|
@@ -93,7 +102,17 @@ function makeFullCtx({ timerAvailable = true } = {}) {
|
|
|
93
102
|
if (name === 'settings') return settingsService
|
|
94
103
|
if (name === 'sessionController') return sessionController
|
|
95
104
|
if (name === 'agents') return { get: (id) => sessions.get(id) }
|
|
96
|
-
if (name === '
|
|
105
|
+
if (name === 'workspaceRegistry') return { archivedSessionIds: [] }
|
|
106
|
+
if (name === 'agentPresets') return { defaultId: '', list: async () => [] }
|
|
107
|
+
if (name === 'sessionQuery') {
|
|
108
|
+
return {
|
|
109
|
+
// 镜像宿主最严准入契约:守护 driver 总是传信号(真实宿主各版本为可选链)
|
|
110
|
+
listSessions: async (signal) => {
|
|
111
|
+
signal.throwIfAborted()
|
|
112
|
+
return [...sessions.values()].map((entry) => ({ id: entry.id }))
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
97
116
|
return undefined
|
|
98
117
|
},
|
|
99
118
|
inject(deps, fn) {
|
package/test/parity.test.mjs
CHANGED
|
@@ -19,7 +19,7 @@ function clientLogic() {
|
|
|
19
19
|
assert.ok(begin >= 0 && end > begin, 'client.js 缺少 LOGIC 标记段')
|
|
20
20
|
const section = source.slice(begin + '/* LOGIC-BEGIN */'.length, end)
|
|
21
21
|
const factory = new Function(
|
|
22
|
-
section + '; return { STATUS_META, TRIGGER_META, KIND_LABELS, MODE_LABELS,
|
|
22
|
+
section + '; return { STATUS_META, TRIGGER_META, KIND_LABELS, MODE_LABELS, relativeTime, formatDateTime, formatDuration, statusMeta };',
|
|
23
23
|
)
|
|
24
24
|
return factory()
|
|
25
25
|
}
|
package/test/routes.test.mjs
CHANGED
|
@@ -24,7 +24,7 @@ async function makeApi(t, overrides = {}) {
|
|
|
24
24
|
})
|
|
25
25
|
const logger = createLogger({ rootDir: join(dir, 'logs') })
|
|
26
26
|
const executor = createExecutor({ store, logger, readMaxConcurrent: () => 2 })
|
|
27
|
-
const api = createApi({ store, logger, executor, ...overrides })
|
|
27
|
+
const api = createApi({ store, logger, executor, getAgentPresets: () => null, ...overrides })
|
|
28
28
|
return { store, api }
|
|
29
29
|
}
|
|
30
30
|
function makeReq(method, body) {
|
|
@@ -325,45 +325,85 @@ test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', asyn
|
|
|
325
325
|
|
|
326
326
|
test('jobs 路由:会话任务字段校验与默认值', async (t) => {
|
|
327
327
|
const { store, api } = await makeApi(t)
|
|
328
|
-
// When 建 pinned 会话任务
|
|
328
|
+
// When 建 pinned 会话任务(携带已废弃的窗口字段与执行预设)
|
|
329
329
|
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
330
330
|
name: '日报', kind: 'session', prompt: '写日报',
|
|
331
331
|
schedule: '0 9 * * *', enabled: true, timeoutMs: 60 * 1000,
|
|
332
|
-
session: { mode: 'pinned', pinnedSessionId: ' s-9 ', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
|
|
332
|
+
session: { mode: 'pinned', pinnedSessionId: ' s-9 ', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer', agentPreset: ' coder ' },
|
|
333
333
|
})
|
|
334
|
-
// Then
|
|
334
|
+
// Then 会话子对象仅落合法字段(窗口三字段已随功能删除,不再产出),id 已裁剪
|
|
335
335
|
assert.equal(created.status, 200)
|
|
336
|
-
assert.deepEqual(created.payload.session, {
|
|
337
|
-
|
|
338
|
-
})
|
|
336
|
+
assert.deepEqual(created.payload.session, { mode: 'pinned', pinnedSessionId: 's-9', agentPreset: 'coder' })
|
|
337
|
+
assert.equal(created.payload.timeoutMs, undefined)
|
|
339
338
|
// When shell 任务不带 session 字段
|
|
340
339
|
const shellJob = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
341
340
|
name: 's1', kind: 'shell', command: 'echo x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
|
|
342
341
|
})
|
|
343
|
-
// Then session
|
|
342
|
+
// Then session 字段缺省,shell 保留显式超时
|
|
344
343
|
assert.equal(shellJob.payload.session, undefined)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
344
|
+
assert.equal(shellJob.payload.timeoutMs, 1000)
|
|
345
|
+
// When 单次运行开关
|
|
346
|
+
const once = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
347
|
+
name: 'once', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true, runOnce: true,
|
|
348
|
+
session: { mode: 'fresh' },
|
|
349
349
|
})
|
|
350
|
-
// Then
|
|
351
|
-
assert.equal(
|
|
352
|
-
assert.
|
|
353
|
-
// When
|
|
354
|
-
const
|
|
355
|
-
name: '
|
|
356
|
-
session: {
|
|
350
|
+
// Then 落库 true;缺省 false
|
|
351
|
+
assert.equal(once.payload.runOnce, true)
|
|
352
|
+
assert.equal(shellJob.payload.runOnce, false)
|
|
353
|
+
// When 非法会话模式
|
|
354
|
+
const badMode = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
355
|
+
name: 's2', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
|
|
356
|
+
session: { mode: 'orphan' },
|
|
357
357
|
})
|
|
358
358
|
// Then 400
|
|
359
|
-
assert.equal(
|
|
360
|
-
// When
|
|
361
|
-
const
|
|
362
|
-
name: '
|
|
363
|
-
session: {
|
|
359
|
+
assert.equal(badMode.status, 400)
|
|
360
|
+
// When 非法执行预设 id(大写/下划线均不合目录 id 形态)
|
|
361
|
+
const badPreset = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
362
|
+
name: 's3', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
|
|
363
|
+
session: { agentPreset: 'Coder_X' },
|
|
364
364
|
})
|
|
365
365
|
// Then 400
|
|
366
|
-
assert.equal(
|
|
366
|
+
assert.equal(badPreset.status, 400)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('agent-presets 路由:目录来自宿主服务,缺失降级空目录', async (t) => {
|
|
370
|
+
// Given 宿主 agentPresets 服务返回混合形态列表
|
|
371
|
+
const service = {
|
|
372
|
+
defaultId: 'deepseek',
|
|
373
|
+
list: async () => [
|
|
374
|
+
{ id: 'deepseek', name: 'DeepSeek 默认' },
|
|
375
|
+
{ id: 'coder' },
|
|
376
|
+
'broken-str',
|
|
377
|
+
{ id: 'Bad_Id' },
|
|
378
|
+
{ id: 'dup' },
|
|
379
|
+
{ id: 'dup', name: '重复项' },
|
|
380
|
+
{ id: 'damaged', broken: true },
|
|
381
|
+
],
|
|
382
|
+
}
|
|
383
|
+
const { api } = await makeApi(t, { getAgentPresets: () => service })
|
|
384
|
+
// When 拉取目录
|
|
385
|
+
const listed = await call(api, 'GET', '/api/cron-board/agent-presets')
|
|
386
|
+
// Then 非法形态/重复/损坏项剔除,字符串条目按 {id,label:id} 接受(dsh-im catalogItem 同构)
|
|
387
|
+
assert.equal(listed.status, 200)
|
|
388
|
+
assert.equal(listed.payload.defaultId, 'deepseek')
|
|
389
|
+
assert.deepEqual(listed.payload.items, [
|
|
390
|
+
{ id: 'deepseek', label: 'DeepSeek 默认' },
|
|
391
|
+
{ id: 'coder', label: 'coder' },
|
|
392
|
+
{ id: 'broken-str', label: 'broken-str' },
|
|
393
|
+
{ id: 'dup', label: 'dup' },
|
|
394
|
+
])
|
|
395
|
+
// When 服务缺失
|
|
396
|
+
const bare = await makeApi(t, { getAgentPresets: () => null })
|
|
397
|
+
const empty = await call(bare.api, 'GET', '/api/cron-board/agent-presets')
|
|
398
|
+
// Then 空目录 200(前端仅「跟随宿主默认」)
|
|
399
|
+
assert.equal(empty.status, 200)
|
|
400
|
+
assert.deepEqual(empty.payload, { defaultId: '', items: [] })
|
|
401
|
+
// When 服务异常
|
|
402
|
+
const throwing = await makeApi(t, { getAgentPresets: () => ({ list: async () => { throw new Error('boom') } }) })
|
|
403
|
+
const failed = await call(throwing.api, 'GET', '/api/cron-board/agent-presets')
|
|
404
|
+
// Then 仍空目录 200
|
|
405
|
+
assert.equal(failed.status, 200)
|
|
406
|
+
assert.deepEqual(failed.payload, { defaultId: '', items: [] })
|
|
367
407
|
})
|
|
368
408
|
|
|
369
409
|
test('jobs 路由:会话通道禁用时建会话任务 400,脚本任务不受限', async (t) => {
|