@mzzsfy/dsh-cron-board 0.1.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.
@@ -0,0 +1,82 @@
1
+ // session-driver:HostApiDriver 会话编排(设计 §4.3,生产实现唯一)。
2
+ // 发起 = 新建/复用会话 + queue 投递;完成判定 = agents 状态脱离 running(轮询 + timeoutMs 收尾);
3
+ // pinned 忙跳过、丢失自愈、发起成功即写 RunRecord.sessionId。变量折叠与掩码由 env 文本构造承担。
4
+
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ import { buildPromptText } from './env-text.mjs'
8
+
9
+ const BUSY_MESSAGE = '上一轮仍在进行'
10
+ const RECREATED_MESSAGE = '原会话丢失已重建'
11
+ const REJECTED_MESSAGE = '会话输入投递被拒绝'
12
+
13
+ const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
14
+
15
+ export function createSessionDriver({ sessionController, agents, sessionQuery, pollIntervalMs = 2 * 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = () => Date.now() }) {
16
+ // pinned 会话存在性:持久层 headers 比对(冷会话不在 agents 注册表)
17
+ async function pinnedExists(sessionId) {
18
+ if (!sessionQuery || typeof sessionQuery.listSessions !== 'function') return Boolean(agents.get(sessionId))
19
+ const records = await sessionQuery.listSessions()
20
+ return records.some((record) => record && record.id === sessionId)
21
+ }
22
+
23
+ async function run({ job, env, mask, updateRecord }) {
24
+ const session = job.session || {}
25
+ const isPinned = session.mode === 'pinned'
26
+ let sessionId = isPinned ? session.pinnedSessionId : null
27
+ let recreated = false
28
+
29
+ if (isPinned) {
30
+ // pinned 串行:上轮未完成直接跳过(不排队)
31
+ const entry = agents ? agents.get(sessionId) : undefined
32
+ if (entry && entry.status === 'running') {
33
+ return { status: 'skipped', message: BUSY_MESSAGE }
34
+ }
35
+ if (!sessionId || !(await pinnedExists(sessionId))) {
36
+ recreated = true
37
+ }
38
+ }
39
+
40
+ if (recreated || !isPinned) {
41
+ const created = await sessionController.create(job.workdir ? { cwd: job.workdir } : undefined)
42
+ sessionId = created.sessionId
43
+ }
44
+
45
+ const submission = await sessionController.prompt({
46
+ requestId: randomUUID(),
47
+ sessionId,
48
+ mode: 'queue',
49
+ content: [{ type: 'text', text: buildPromptText({ prompt: job.prompt || '', env, mask }) }],
50
+ })
51
+ if (!submission || submission.accepted !== true) {
52
+ return { status: 'fail', message: REJECTED_MESSAGE, sessionId }
53
+ }
54
+ if (typeof updateRecord === 'function') {
55
+ await updateRecord({ sessionId })
56
+ }
57
+
58
+ // 完成等待:状态脱离 running 即结算,timeoutMs 到点收尾
59
+ const timeoutMs = Number.isInteger(job.timeoutMs) && job.timeoutMs > 0 ? job.timeoutMs : DEFAULT_TIMEOUT_MS
60
+ const deadline = now() + timeoutMs
61
+ while (now() < deadline) {
62
+ const entry = agents ? agents.get(sessionId) : undefined
63
+ if (!entry || entry.status !== 'running') {
64
+ return {
65
+ status: 'success',
66
+ sessionId,
67
+ ...(recreated ? { pinnedNewId: sessionId, message: RECREATED_MESSAGE } : {}),
68
+ }
69
+ }
70
+ await sleep(pollIntervalMs)
71
+ }
72
+ return {
73
+ status: 'timeout',
74
+ sessionId,
75
+ ...(recreated ? { pinnedNewId: sessionId, message: RECREATED_MESSAGE + ';超时未完成' } : {}),
76
+ }
77
+ }
78
+
79
+ return {
80
+ run,
81
+ }
82
+ }
@@ -0,0 +1,37 @@
1
+ // session-window:会话任务时间窗口判定(设计 §4.3)。start>=end 视为跨零点(如 22:00-06:00)。
2
+
3
+ const MINUTES_PER_DAY = 24 * 60
4
+
5
+ function parseMinutes(hhmm) {
6
+ const [hours, minutes] = String(hhmm).split(':').map((part) => parseInt(part, 10))
7
+ return hours * 60 + minutes
8
+ }
9
+
10
+ function minutesOfDay(date) {
11
+ return date.getHours() * 60 + date.getMinutes()
12
+ }
13
+
14
+ // 触发时刻是否落在窗口内;start>=end 即跨零点区间。session = job.session 子对象
15
+ export function inWindow(session, date) {
16
+ const start = parseMinutes(session.windowStart)
17
+ const end = parseMinutes(session.windowEnd)
18
+ const nowMinutes = minutesOfDay(date)
19
+ if (start <= end) return nowMinutes >= start && nowMinutes < end
20
+ return nowMinutes >= start || nowMinutes < end
21
+ }
22
+
23
+ // 最近的窗口起点时刻(defer 挂起目标):起点未过用今日,已过用明日
24
+ export function nextWindowStart(session, date) {
25
+ const start = parseMinutes(session.windowStart)
26
+ const at = new Date(date)
27
+ at.setSeconds(0, 0)
28
+ at.setHours(Math.floor(start / 60), start % 60)
29
+ if (at.getTime() <= date.getTime()) at.setDate(at.getDate() + 1)
30
+ return at
31
+ }
32
+
33
+ export function hasWindow(session) {
34
+ return Boolean(session && session.windowStart && session.windowEnd)
35
+ }
36
+
37
+ export { MINUTES_PER_DAY }
@@ -0,0 +1,71 @@
1
+ // ShellRunner:shell 形式执行 command,stdout/stderr 合流写 logSink(日志文件即执行现场);
2
+ // 环境变量注入叠加 process.env 基础之上;超时强制终止进程树记 timeout。
3
+ // Windows 下 shell:true 生成 cmd.exe 包装进程,直接 kill 不杀孙进程且管道不关闭,
4
+ // 须按平台杀整棵进程树:Windows taskkill /T,POSIX detached 进程组信号。
5
+
6
+ import { spawn } from 'node:child_process'
7
+
8
+ const KILL_GRACE_MS = 5 * 1000
9
+
10
+ const isWindows = process.platform === 'win32'
11
+
12
+ // 杀整棵进程树;调用方保证 child 仍在运行
13
+ function killTree(child) {
14
+ if (isWindows) {
15
+ spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' })
16
+ return
17
+ }
18
+ try {
19
+ process.kill(-child.pid, 'SIGTERM')
20
+ } catch {
21
+ child.kill('SIGKILL')
22
+ }
23
+ }
24
+
25
+ export function createShellRunner({ workdirFallback }) {
26
+ return {
27
+ run({ job, env, logSink }) {
28
+ return new Promise((resolve) => {
29
+ const child = spawn(job.command, {
30
+ shell: true,
31
+ cwd: job.workdir || workdirFallback,
32
+ env: { ...process.env, ...env },
33
+ detached: !isWindows,
34
+ })
35
+ let timedOut = false
36
+ let timeoutGuard
37
+
38
+ const onChunk = (chunk) => {
39
+ logSink.append(chunk.toString('utf8'))
40
+ }
41
+ child.stdout.on('data', onChunk)
42
+ child.stderr.on('data', onChunk)
43
+
44
+ if (job.timeoutMs > 0) {
45
+ timeoutGuard = setTimeout(() => {
46
+ timedOut = true
47
+ logSink.append('\n[cron-board] 运行超时,强制终止\n')
48
+ killTree(child)
49
+ // 终止宽限兜底:树未被清干净时直接 SIGKILL 兜底(POSIX;Windows /F 已强杀)
50
+ setTimeout(() => {
51
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
52
+ }, KILL_GRACE_MS)
53
+ }, job.timeoutMs)
54
+ }
55
+
56
+ child.on('error', (error) => {
57
+ clearTimeout(timeoutGuard)
58
+ logSink.append('\n[cron-board] 进程启动失败: ' + String(error) + '\n')
59
+ resolve({ status: 'fail', exitCode: null })
60
+ })
61
+ child.on('close', (code) => {
62
+ clearTimeout(timeoutGuard)
63
+ resolve({
64
+ status: timedOut ? 'timeout' : code === 0 ? 'success' : 'fail',
65
+ exitCode: code,
66
+ })
67
+ })
68
+ })
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,16 @@
1
+ // status-meta:运行状态五态+中断的展示元数据(core 侧单一事实源)。
2
+ // client.js LOGIC 段镜像此表,parity 测试锁定(双实现同源规约)。
3
+
4
+ export const STATUS_META = {
5
+ success: { label: '成功', tone: 'ok' },
6
+ fail: { label: '失败', tone: 'bad' },
7
+ timeout: { label: '超时', tone: 'warn' },
8
+ skipped: { label: '跳过', tone: 'mute' },
9
+ interrupted: { label: '中断', tone: 'mute' },
10
+ running: { label: '运行中', tone: 'run' },
11
+ }
12
+
13
+ export const TRIGGER_META = {
14
+ cron: { label: '定时' },
15
+ manual: { label: '手动' },
16
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // store:envs/jobs/runs 三份 JSON 的原子读写。读走内存缓存,写经进程内互斥链串行化,
2
+ // 单次写 = 临时文件 + 改名(原子替换);坏文件备份 .bak 后拒绝该集合写入,防空 store 覆盖。
3
+
4
+ import { randomUUID } from 'node:crypto'
5
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
6
+ import { dirname, join } from 'node:path'
7
+
8
+ const BAK_SUFFIX = '.bak'
9
+ const TMP_SUFFIX = '.tmp'
10
+
11
+ // Windows 下对刚写入目标的替换 rename 会报瞬态 EACCES/EBUSY/EPERM(Defender/索引器短暂锁定),
12
+ // 有界重试消解;节奏镜像官方 dsh-atomic-write 的同名处理
13
+ const RETRY_INITIAL_MS = 20
14
+ const RETRY_MAX_MS = 200
15
+ const RETRY_LIMIT = 8
16
+
17
+ function isTransientRenameError(error) {
18
+ return process.platform === 'win32' && ['EACCES', 'EBUSY', 'EPERM'].includes(error && error.code)
19
+ }
20
+
21
+ async function renameWithRetry(temp, file) {
22
+ let delay = RETRY_INITIAL_MS
23
+ for (let attempt = 0; ; attempt++) {
24
+ try {
25
+ await rename(temp, file)
26
+ return
27
+ } catch (error) {
28
+ if (!isTransientRenameError(error) || attempt >= RETRY_LIMIT) throw error
29
+ }
30
+ await new Promise((resolve) => setTimeout(resolve, delay))
31
+ delay = Math.min(delay * 2, RETRY_MAX_MS)
32
+ }
33
+ }
34
+
35
+ // 每任务运行记录元数据保留上限:超限裁最旧,防 runs.json 无界增长
36
+ export const RUNS_KEEP_PER_JOB = 200
37
+
38
+ function isMissing(error) {
39
+ return Boolean(error) && error.code === 'ENOENT'
40
+ }
41
+
42
+ // 单集合持久化:加载/读缓存/互斥链写。所有集合经 link 容器共享一条写链(link.chain 重绑),
43
+ // 保证跨文件写序与进程内串行;损坏标记集合级隔离:单文件损坏只禁该集合写,其余集合不受连坐
44
+ function createCollection({ file, link, idKey }) {
45
+ let rows = []
46
+ let loaded = false
47
+ let broken = false
48
+
49
+ function assertUsable() {
50
+ if (broken) throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失: ' + file)
51
+ }
52
+
53
+ async function persistLocked() {
54
+ await mkdir(dirname(file), { recursive: true })
55
+ const tmp = file + TMP_SUFFIX
56
+ await writeFile(tmp, JSON.stringify(rows), 'utf8')
57
+ await renameWithRetry(tmp, file)
58
+ }
59
+
60
+ return {
61
+ get rows() {
62
+ return rows
63
+ },
64
+ async load() {
65
+ if (loaded) return
66
+ loaded = true
67
+ try {
68
+ const text = await readFile(file, 'utf8')
69
+ const parsed = JSON.parse(text)
70
+ if (Array.isArray(parsed)) rows = parsed
71
+ } catch (error) {
72
+ if (!isMissing(error)) {
73
+ broken = true
74
+ await rename(file, file + BAK_SUFFIX).catch(() => {})
75
+ }
76
+ }
77
+ },
78
+ list(filter) {
79
+ const source = filter ? rows.filter(filter) : rows
80
+ // 对外浅拷贝:防调用方原地修改污染内存态(mutate 以 Object.assign 原地生效)
81
+ return source.map((row) => ({ ...row }))
82
+ },
83
+ get(id) {
84
+ const row = rows.find((item) => item[idKey] === id)
85
+ return row ? { ...row } : undefined
86
+ },
87
+ // 读改写经共享互斥链串行化;链上失败不传播到后续写,调用方 await 本次结果感知单次失败
88
+ mutate(fn) {
89
+ assertUsable()
90
+ const result = link.chain.then(async () => {
91
+ const outcome = await fn(rows)
92
+ if (outcome !== false) await persistLocked()
93
+ return outcome
94
+ })
95
+ link.chain = result.catch(() => {})
96
+ return result
97
+ },
98
+ async create(data) {
99
+ const now = Date.now()
100
+ const row = {
101
+ ...data,
102
+ [idKey]: data[idKey] || randomUUID(),
103
+ createdAt: now,
104
+ updatedAt: now,
105
+ }
106
+ await this.mutate((list) => {
107
+ list.push(row)
108
+ })
109
+ return row
110
+ },
111
+ async update(id, patch) {
112
+ let updated
113
+ await this.mutate((list) => {
114
+ const row = list.find((item) => item[idKey] === id)
115
+ if (row === undefined) return false
116
+ Object.assign(row, patch, { updatedAt: Date.now() })
117
+ updated = row
118
+ })
119
+ return updated
120
+ },
121
+ async remove(id) {
122
+ let removed = false
123
+ await this.mutate((list) => {
124
+ const index = list.findIndex((item) => item[idKey] === id)
125
+ if (index < 0) return false
126
+ list.splice(index, 1)
127
+ removed = true
128
+ })
129
+ return removed
130
+ },
131
+ }
132
+ }
133
+
134
+ export async function createStore({ dir }) {
135
+ const link = { chain: Promise.resolve() }
136
+ const makeFile = (name) => join(dir, name)
137
+
138
+ const envs = createCollection({ file: makeFile('envs.json'), link, idKey: 'id' })
139
+ const jobsBase = createCollection({ file: makeFile('jobs.json'), link, idKey: 'id' })
140
+ const runs = createCollection({ file: makeFile('runs.json'), link, idKey: 'runId' })
141
+ await Promise.all([envs.load(), jobsBase.load(), runs.load()])
142
+
143
+ // 任务删除连带清除其运行记录(日志目录由调用方连带清理)
144
+ const jobs = {
145
+ get rows() {
146
+ return jobsBase.rows
147
+ },
148
+ load: () => jobsBase.load(),
149
+ list: (filter) => jobsBase.list(filter),
150
+ get: (id) => jobsBase.get(id),
151
+ mutate: (fn) => jobsBase.mutate(fn),
152
+ create: (data) => jobsBase.create(data),
153
+ update: (id, patch) => jobsBase.update(id, patch),
154
+ async remove(id) {
155
+ const removed = await jobsBase.remove(id)
156
+ if (!removed) return false
157
+ await runs.mutate((list) => {
158
+ const keep = list.filter((row) => row.jobId !== id)
159
+ list.length = 0
160
+ list.push(...keep)
161
+ })
162
+ return true
163
+ },
164
+ }
165
+
166
+ // 运行记录创建:同一任务超出保留上限时裁掉最旧(数组头部新,尾部旧)
167
+ async function createRun(data) {
168
+ const row = await runs.create(data)
169
+ await runs.mutate((list) => {
170
+ const mine = list.filter((item) => item.jobId === row.jobId)
171
+ const excess = mine.length - RUNS_KEEP_PER_JOB
172
+ if (excess <= 0) return false
173
+ const staleIds = new Set(mine.slice(0, excess).map((item) => item.runId))
174
+ const keep = list.filter((item) => !(item.jobId === row.jobId && staleIds.has(item.runId)))
175
+ list.length = 0
176
+ list.push(...keep)
177
+ })
178
+ return row
179
+ }
180
+
181
+ return {
182
+ envs,
183
+ jobs,
184
+ runs: {
185
+ get rows() {
186
+ return runs.rows
187
+ },
188
+ list: (jobId) => (jobId ? runs.list((row) => row.jobId === jobId) : runs.list()),
189
+ get: (runId) => runs.get(runId),
190
+ create: createRun,
191
+ update: (runId, patch) => runs.update(runId, patch),
192
+ remove: (runId) => runs.remove(runId),
193
+ },
194
+ }
195
+ }
@@ -0,0 +1,101 @@
1
+ // 环境变量同名多值展开测试(青龙多账号语义,设计 §4.2)。
2
+
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+
6
+ import { expandEnvMatrix } from '../src/env-expand.mjs'
7
+
8
+ test('env-expand:同名多值展开为笛卡尔积,单值名称每组携带', () => {
9
+ // Given 启用变量 A=1/A=2(同名多值)与 B=x
10
+ const envs = [
11
+ { name: 'A', value: '1', enabled: true },
12
+ { name: 'A', value: '2', enabled: true },
13
+ { name: 'B', value: 'x', enabled: true },
14
+ ]
15
+ // When 展开
16
+ const { combinations, truncated } = expandEnvMatrix({ envs, maxExpansion: 20 })
17
+ // Then 两组:第一次 A=1,B=x;第二次 A=2,B=x
18
+ assert.equal(truncated, false)
19
+ assert.deepEqual(combinations, [
20
+ { A: '1', B: 'x' },
21
+ { A: '2', B: 'x' },
22
+ ])
23
+ })
24
+
25
+ test('env-expand:禁用变量不注入', () => {
26
+ // Given 含禁用变量 C=3
27
+ const envs = [
28
+ { name: 'A', value: '1', enabled: true },
29
+ { name: 'C', value: '3', enabled: false },
30
+ ]
31
+ // When 展开
32
+ const { combinations } = expandEnvMatrix({ envs, maxExpansion: 20 })
33
+ // Then C 不出现
34
+ assert.deepEqual(combinations, [{ A: '1' }])
35
+ })
36
+
37
+ test('env-expand:空集合产出单组空组合,shell 任务无变量也可运行', () => {
38
+ // Given 无启用变量
39
+ // When 展开
40
+ const { combinations } = expandEnvMatrix({ envs: [], maxExpansion: 20 })
41
+ // Then 一组空组合
42
+ assert.deepEqual(combinations, [{}])
43
+ })
44
+
45
+ test('env-expand:组合数超上限截断并标记', () => {
46
+ // Given 三个名称各 4 个启用值 = 64 组,上限 20
47
+ const names = ['A', 'B', 'C']
48
+ const envs = []
49
+ for (const name of names) {
50
+ for (let i = 1; i <= 4; i++) envs.push({ name, value: String(i), enabled: true })
51
+ }
52
+ // When 展开
53
+ const { combinations, truncated } = expandEnvMatrix({ envs, maxExpansion: 20 })
54
+ // Then 仅前 20 组且 truncated=true
55
+ assert.equal(combinations.length, 20)
56
+ assert.equal(truncated, true)
57
+ })
58
+
59
+ test('env-expand:大组合限量生成,不物化指数级全量', () => {
60
+ // Given 两个名称各 30 值 = 900 组,上限 10
61
+ const envs = []
62
+ for (const name of ['A', 'B']) {
63
+ for (let i = 1; i <= 30; i++) envs.push({ name, value: String(i), enabled: true })
64
+ }
65
+ // When 展开
66
+ const { combinations, truncated } = expandEnvMatrix({ envs, maxExpansion: 10 })
67
+ // Then 恰好 10 组且每组结构完整(前缀组合,而非部分展开的残缺组)
68
+ assert.equal(truncated, true)
69
+ assert.equal(combinations.length, 10)
70
+ for (const row of combinations) {
71
+ assert.ok(row.A !== undefined && row.B !== undefined)
72
+ }
73
+ })
74
+
75
+ test('env-expand:multi 行按换行拆值,空行与首尾空白剔除', () => {
76
+ // Given 单行 multi 存储形态(客户端 values 数组经 API 折叠为换行拼接)
77
+ const envs = [
78
+ { name: 'TOKEN', value: 'aaa\n\n bbb \n', multi: true, enabled: true },
79
+ { name: 'MODE', value: 'run', enabled: true },
80
+ ]
81
+ // When 展开
82
+ const { combinations, truncated } = expandEnvMatrix({ envs, maxExpansion: 20 })
83
+ // Then 拆出两值,单值名称每组携带
84
+ assert.equal(truncated, false)
85
+ assert.deepEqual(combinations, [
86
+ { TOKEN: 'aaa', MODE: 'run' },
87
+ { TOKEN: 'bbb', MODE: 'run' },
88
+ ])
89
+ })
90
+
91
+ test('env-expand:multi 行与同名单行合并贡献组合', () => {
92
+ // Given multi 行两值 + 同名单行一值
93
+ const envs = [
94
+ { name: 'A', value: '1\n2', multi: true, enabled: true },
95
+ { name: 'A', value: '3', enabled: true },
96
+ ]
97
+ // When 展开
98
+ const { combinations } = expandEnvMatrix({ envs, maxExpansion: 20 })
99
+ // Then 三组按序
100
+ assert.deepEqual(combinations.map((row) => row.A), ['1', '2', '3'])
101
+ })
@@ -0,0 +1,107 @@
1
+ // executor 执行链 BDD:runner 拒绝的终态收尾与部分写失败下的历史保护。
2
+ // 场景对应 runUnit catch:补写不覆写已落库终态、补写失败经 logSystem 留痕。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ import { createStore } from '../src/store.mjs'
11
+ import { createLogger } from '../src/logger.mjs'
12
+ import { createExecutor } from '../src/executor.mjs'
13
+
14
+ async function makeExecutor(t, { jobsBroken = false, logLines = [] } = {}) {
15
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-exec-'))
16
+ // Windows 下并行测试时 Defender/索引器短暂锁目录,rmdir 报瞬态 EBUSY,有界重试消解
17
+ t.after(() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
18
+ const store = await createStore({ dir: join(dir, 'data') })
19
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
20
+ if (jobsBroken) {
21
+ // 模拟 jobs 集合损坏(mutate 直接拒绝),runs 集合保持健康
22
+ store.jobs.update = async () => { throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失') }
23
+ }
24
+ const runner = {
25
+ run: async () => ({ status: 'success', exitCode: 0 }),
26
+ }
27
+ const executor = createExecutor({
28
+ store,
29
+ logger,
30
+ runner,
31
+ readMaxConcurrent: () => 2,
32
+ logSystem: (line) => logLines.push(line),
33
+ })
34
+ return { store, executor, logLines }
35
+ }
36
+
37
+ const JOB_BASE = { name: 't', kind: 'shell', command: 'echo hi', schedule: '* * * * *', enabled: true, timeoutMs: 60 * 1000 }
38
+
39
+ async function dispatchAndSettle(executor, store, jobId) {
40
+ const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
41
+ // 等执行链收尾:pump 后单元在后台,轮询至终态或超时
42
+ for (let i = 0; i < 200; i++) {
43
+ const row = store.runs.get(runId)
44
+ if (row && row.status !== 'queued' && row.status !== 'running') return runId
45
+ await new Promise((resolve) => setTimeout(resolve, 10))
46
+ }
47
+ return runId
48
+ }
49
+
50
+ test('executor:正常路径终态 success 且任务卡回填', async (t) => {
51
+ const { store, executor } = await makeExecutor(t)
52
+ await store.jobs.create({ ...JOB_BASE, id: 'j1' })
53
+ const runId = await dispatchAndSettle(executor, store, 'j1')
54
+ assert.equal(store.runs.get(runId).status, 'success')
55
+ assert.equal(store.jobs.get('j1').lastStatus, 'success')
56
+ })
57
+
58
+ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
59
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-exec-'))
60
+ t.after(() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
61
+ const store = await createStore({ dir: join(dir, 'data') })
62
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
63
+ const logLines = []
64
+ const executor = createExecutor({
65
+ store,
66
+ logger,
67
+ runner: { run: async () => { throw new Error('会话服务爆炸') } },
68
+ readMaxConcurrent: () => 2,
69
+ logSystem: (line) => logLines.push(line),
70
+ })
71
+ await store.jobs.create({ ...JOB_BASE, id: 'j2' })
72
+ const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j2' }, 'manual')
73
+ for (let i = 0; i < 200; i++) {
74
+ const row = store.runs.get(runId)
75
+ if (row && row.status === 'fail') break
76
+ await new Promise((resolve) => setTimeout(resolve, 10))
77
+ }
78
+ const row = store.runs.get(runId)
79
+ assert.equal(row.status, 'fail')
80
+ assert.match(row.message, /会话服务爆炸/)
81
+ assert.equal(store.jobs.get('j2').lastStatus, 'fail')
82
+ assert.equal(logLines.length, 0, '补写成功不留痕')
83
+ })
84
+
85
+ test('executor:运行终态已落库而任务卡回填失败,历史不被覆写', async (t) => {
86
+ const { store, executor, logLines } = await makeExecutor(t, { jobsBroken: true })
87
+ await store.jobs.create({ ...JOB_BASE, id: 'j3' })
88
+ const runId = await dispatchAndSettle(executor, store, 'j3')
89
+ // Then 事实成功保留,不被 catch 覆写为 fail;回填缺口留痕可观测
90
+ assert.equal(store.runs.get(runId).status, 'success')
91
+ assert.equal(logLines.length, 1)
92
+ assert.ok(logLines[0].includes(runId))
93
+ assert.ok(logLines[0].includes('任务卡回填失败'))
94
+ })
95
+
96
+ test('executor:运行终态补写失败经 logSystem 留痕', async (t) => {
97
+ const { store, executor, logLines } = await makeExecutor(t)
98
+ await store.jobs.create({ ...JOB_BASE, id: 'j4' })
99
+ const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j4' }, 'manual')
100
+ // dispatch 完成(记录已落库、runUnit 已入队)后再让 runs 集合损坏:终态写入必失败
101
+ store.runs.update = async () => { throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失') }
102
+ await new Promise((resolve) => setTimeout(resolve, 200))
103
+ // Then 补写失败恰留痕一次,含 runId 与原始错误
104
+ assert.equal(logLines.length, 1)
105
+ assert.ok(logLines[0].includes(runId))
106
+ assert.ok(logLines[0].includes('补写失败'))
107
+ })