@mzzsfy/dsh-cron-board 0.2.1 → 0.3.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 +6 -6
- package/package.json +1 -1
- package/src/api.mjs +47 -18
- package/src/client.js +289 -92
- package/src/executor.mjs +10 -1
- package/src/index.js +7 -1
- package/src/schedule-builder.mjs +73 -0
- package/src/scheduler.mjs +15 -40
- 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 +98 -25
- package/test/schedule-builder.test.mjs +123 -0
- package/test/scheduler.test.mjs +47 -131
- package/test/session-driver.test.mjs +167 -31
- package/src/session-window.mjs +0 -37
|
@@ -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,41 +1,33 @@
|
|
|
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
|
+
// 历史版本编辑残留的自愈:nextRunAt 不在当前 schedule 的触发点上即重算,
|
|
15
|
+
// 防残留值到点误执行;合法触发点与空值(无未来触发点)不动,保留宽限执行与 misfire 语义
|
|
16
|
+
function isLegalTrigger(schedule, at) {
|
|
17
|
+
return nextRunAtOf(schedule, new Date(at - 1000)) === at
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 重启恢复:孤儿运行收尾 + 触发点残留自愈
|
|
19
21
|
async function recover() {
|
|
20
22
|
for (const run of [...store.runs.rows]) {
|
|
21
23
|
if (run.status !== 'queued' && run.status !== 'running') continue
|
|
22
24
|
await store.runs.update(run.runId, { status: 'interrupted', endedAt: now(), message: INTERRUPTED_MESSAGE })
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
|
26
|
+
for (const job of store.jobs.list()) {
|
|
27
|
+
if (typeof job.nextRunAt !== 'number' || Number.isNaN(job.nextRunAt)) continue
|
|
28
|
+
if (isLegalTrigger(job.schedule, job.nextRunAt)) continue
|
|
29
|
+
await store.jobs.update(job.id, { nextRunAt: nextRunAtOf(job.schedule, new Date(now())) })
|
|
30
|
+
}
|
|
39
31
|
}
|
|
40
32
|
|
|
41
33
|
async function tick() {
|
|
@@ -58,15 +50,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
58
50
|
await store.runs.create({ jobId: job.id, trigger: 'cron', status: 'skipped', message: SKIPPED_MISFIRE_MESSAGE })
|
|
59
51
|
continue
|
|
60
52
|
}
|
|
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
53
|
await executor.dispatch(job, 'cron')
|
|
71
54
|
}
|
|
72
55
|
} finally {
|
|
@@ -74,13 +57,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
74
57
|
}
|
|
75
58
|
}
|
|
76
59
|
|
|
77
|
-
function dispose() {
|
|
78
|
-
for (const cancel of deferredCancels) {
|
|
79
|
-
try { cancel() } catch { /* 句柄已自行失效即无清理需求 */ }
|
|
80
|
-
}
|
|
81
|
-
deferredCancels.clear()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
60
|
// 下次触发预览:启用任务中最小的 nextRunAt(面板状态展示用)
|
|
85
61
|
function nextRunAt() {
|
|
86
62
|
const dueList = store.jobs
|
|
@@ -92,7 +68,6 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
92
68
|
return {
|
|
93
69
|
recover,
|
|
94
70
|
tick,
|
|
95
|
-
dispose,
|
|
96
71
|
status: () => executor.status(),
|
|
97
72
|
nextRunAt,
|
|
98
73
|
}
|
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
|
@@ -12,6 +12,7 @@ import { createStore } from '../src/store.mjs'
|
|
|
12
12
|
import { createLogger } from '../src/logger.mjs'
|
|
13
13
|
import { createExecutor } from '../src/executor.mjs'
|
|
14
14
|
import { createApi } from '../src/api.mjs'
|
|
15
|
+
import { nextRunAtOf } from '../src/cron.mjs'
|
|
15
16
|
|
|
16
17
|
async function makeApi(t, overrides = {}) {
|
|
17
18
|
const dir = await mkdtemp(join(tmpdir(), 'cron-board-api-'))
|
|
@@ -24,7 +25,7 @@ async function makeApi(t, overrides = {}) {
|
|
|
24
25
|
})
|
|
25
26
|
const logger = createLogger({ rootDir: join(dir, 'logs') })
|
|
26
27
|
const executor = createExecutor({ store, logger, readMaxConcurrent: () => 2 })
|
|
27
|
-
const api = createApi({ store, logger, executor, ...overrides })
|
|
28
|
+
const api = createApi({ store, logger, executor, getAgentPresets: () => null, ...overrides })
|
|
28
29
|
return { store, api }
|
|
29
30
|
}
|
|
30
31
|
function makeReq(method, body) {
|
|
@@ -192,6 +193,38 @@ test('jobs 路由:非法 cron 表达式被拒', async (t) => {
|
|
|
192
193
|
assert.match(res.payload.error, /cron/)
|
|
193
194
|
})
|
|
194
195
|
|
|
196
|
+
test('jobs 路由:编辑保存按当前 schedule 重算 nextRunAt', async (t) => {
|
|
197
|
+
const { api } = await makeApi(t)
|
|
198
|
+
// Given 任务按工作日 09:55 排期
|
|
199
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
200
|
+
name: '改期', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
201
|
+
})
|
|
202
|
+
assert.equal(created.status, 200)
|
|
203
|
+
const jobId = created.payload.id
|
|
204
|
+
const before = created.payload.nextRunAt
|
|
205
|
+
// When 编辑改为每天 09:00(表单契约:请求体不带 nextRunAt)
|
|
206
|
+
const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + jobId, { schedule: '0 9 * * *', prompt: 'x' })
|
|
207
|
+
// Then 触发点随新表达式重算,不残留工作日旧值
|
|
208
|
+
assert.equal(patched.status, 200)
|
|
209
|
+
assert.notEqual(patched.payload.nextRunAt, before)
|
|
210
|
+
assert.equal(patched.payload.nextRunAt, nextRunAtOf('0 9 * * *'))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('jobs 路由:PATCH 显式 nextRunAt 优先于重算(导入保留原状态契约)', async (t) => {
|
|
214
|
+
const { api } = await makeApi(t)
|
|
215
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
216
|
+
name: '导入', kind: 'shell', command: 'x', schedule: '* * * * *',
|
|
217
|
+
})
|
|
218
|
+
assert.equal(created.status, 200)
|
|
219
|
+
// When 请求体显式携带 nextRunAt
|
|
220
|
+
const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + created.payload.id, {
|
|
221
|
+
schedule: '0 9 * * *', nextRunAt: 1750000000000,
|
|
222
|
+
})
|
|
223
|
+
// Then 显式值落库,不被重算覆盖
|
|
224
|
+
assert.equal(patched.status, 200)
|
|
225
|
+
assert.equal(patched.payload.nextRunAt, 1750000000000)
|
|
226
|
+
})
|
|
227
|
+
|
|
195
228
|
test('jobs 路由:手动运行端到端——RunRecord 产生、日志可读、状态终态', async (t) => {
|
|
196
229
|
// Given shell 任务(node -e 输出标记)
|
|
197
230
|
const { store, api } = await makeApi(t)
|
|
@@ -325,45 +358,85 @@ test('status 路由:timer 缺失降级时 timerRunning=false 且带原因', asyn
|
|
|
325
358
|
|
|
326
359
|
test('jobs 路由:会话任务字段校验与默认值', async (t) => {
|
|
327
360
|
const { store, api } = await makeApi(t)
|
|
328
|
-
// When 建 pinned 会话任务
|
|
361
|
+
// When 建 pinned 会话任务(携带已废弃的窗口字段与执行预设)
|
|
329
362
|
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
330
363
|
name: '日报', kind: 'session', prompt: '写日报',
|
|
331
364
|
schedule: '0 9 * * *', enabled: true, timeoutMs: 60 * 1000,
|
|
332
|
-
session: { mode: 'pinned', pinnedSessionId: ' s-9 ', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
|
|
365
|
+
session: { mode: 'pinned', pinnedSessionId: ' s-9 ', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer', agentPreset: ' coder ' },
|
|
333
366
|
})
|
|
334
|
-
// Then
|
|
367
|
+
// Then 会话子对象仅落合法字段(窗口三字段已随功能删除,不再产出),id 已裁剪
|
|
335
368
|
assert.equal(created.status, 200)
|
|
336
|
-
assert.deepEqual(created.payload.session, {
|
|
337
|
-
|
|
338
|
-
})
|
|
369
|
+
assert.deepEqual(created.payload.session, { mode: 'pinned', pinnedSessionId: 's-9', agentPreset: 'coder' })
|
|
370
|
+
assert.equal(created.payload.timeoutMs, undefined)
|
|
339
371
|
// When shell 任务不带 session 字段
|
|
340
372
|
const shellJob = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
341
373
|
name: 's1', kind: 'shell', command: 'echo x', schedule: '* * * * *', enabled: true, timeoutMs: 1000,
|
|
342
374
|
})
|
|
343
|
-
// Then session
|
|
375
|
+
// Then session 字段缺省,shell 保留显式超时
|
|
344
376
|
assert.equal(shellJob.payload.session, undefined)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
377
|
+
assert.equal(shellJob.payload.timeoutMs, 1000)
|
|
378
|
+
// When 单次运行开关
|
|
379
|
+
const once = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
380
|
+
name: 'once', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true, runOnce: true,
|
|
381
|
+
session: { mode: 'fresh' },
|
|
349
382
|
})
|
|
350
|
-
// Then
|
|
351
|
-
assert.equal(
|
|
352
|
-
assert.
|
|
353
|
-
// When
|
|
354
|
-
const
|
|
355
|
-
name: '
|
|
356
|
-
session: {
|
|
383
|
+
// Then 落库 true;缺省 false
|
|
384
|
+
assert.equal(once.payload.runOnce, true)
|
|
385
|
+
assert.equal(shellJob.payload.runOnce, false)
|
|
386
|
+
// When 非法会话模式
|
|
387
|
+
const badMode = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
388
|
+
name: 's2', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
|
|
389
|
+
session: { mode: 'orphan' },
|
|
357
390
|
})
|
|
358
391
|
// Then 400
|
|
359
|
-
assert.equal(
|
|
360
|
-
// When
|
|
361
|
-
const
|
|
362
|
-
name: '
|
|
363
|
-
session: {
|
|
392
|
+
assert.equal(badMode.status, 400)
|
|
393
|
+
// When 非法执行预设 id(大写/下划线均不合目录 id 形态)
|
|
394
|
+
const badPreset = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
395
|
+
name: 's3', kind: 'session', prompt: 'x', schedule: '* * * * *', enabled: true,
|
|
396
|
+
session: { agentPreset: 'Coder_X' },
|
|
364
397
|
})
|
|
365
398
|
// Then 400
|
|
366
|
-
assert.equal(
|
|
399
|
+
assert.equal(badPreset.status, 400)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('agent-presets 路由:目录来自宿主服务,缺失降级空目录', async (t) => {
|
|
403
|
+
// Given 宿主 agentPresets 服务返回混合形态列表
|
|
404
|
+
const service = {
|
|
405
|
+
defaultId: 'deepseek',
|
|
406
|
+
list: async () => [
|
|
407
|
+
{ id: 'deepseek', name: 'DeepSeek 默认' },
|
|
408
|
+
{ id: 'coder' },
|
|
409
|
+
'broken-str',
|
|
410
|
+
{ id: 'Bad_Id' },
|
|
411
|
+
{ id: 'dup' },
|
|
412
|
+
{ id: 'dup', name: '重复项' },
|
|
413
|
+
{ id: 'damaged', broken: true },
|
|
414
|
+
],
|
|
415
|
+
}
|
|
416
|
+
const { api } = await makeApi(t, { getAgentPresets: () => service })
|
|
417
|
+
// When 拉取目录
|
|
418
|
+
const listed = await call(api, 'GET', '/api/cron-board/agent-presets')
|
|
419
|
+
// Then 非法形态/重复/损坏项剔除,字符串条目按 {id,label:id} 接受(dsh-im catalogItem 同构)
|
|
420
|
+
assert.equal(listed.status, 200)
|
|
421
|
+
assert.equal(listed.payload.defaultId, 'deepseek')
|
|
422
|
+
assert.deepEqual(listed.payload.items, [
|
|
423
|
+
{ id: 'deepseek', label: 'DeepSeek 默认' },
|
|
424
|
+
{ id: 'coder', label: 'coder' },
|
|
425
|
+
{ id: 'broken-str', label: 'broken-str' },
|
|
426
|
+
{ id: 'dup', label: 'dup' },
|
|
427
|
+
])
|
|
428
|
+
// When 服务缺失
|
|
429
|
+
const bare = await makeApi(t, { getAgentPresets: () => null })
|
|
430
|
+
const empty = await call(bare.api, 'GET', '/api/cron-board/agent-presets')
|
|
431
|
+
// Then 空目录 200(前端仅「跟随宿主默认」)
|
|
432
|
+
assert.equal(empty.status, 200)
|
|
433
|
+
assert.deepEqual(empty.payload, { defaultId: '', items: [] })
|
|
434
|
+
// When 服务异常
|
|
435
|
+
const throwing = await makeApi(t, { getAgentPresets: () => ({ list: async () => { throw new Error('boom') } }) })
|
|
436
|
+
const failed = await call(throwing.api, 'GET', '/api/cron-board/agent-presets')
|
|
437
|
+
// Then 仍空目录 200
|
|
438
|
+
assert.equal(failed.status, 200)
|
|
439
|
+
assert.deepEqual(failed.payload, { defaultId: '', items: [] })
|
|
367
440
|
})
|
|
368
441
|
|
|
369
442
|
test('jobs 路由:会话通道禁用时建会话任务 400,脚本任务不受限', async (t) => {
|