@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.
package/src/config.mjs ADDED
@@ -0,0 +1,10 @@
1
+ // 全局默认常量:宿主 settings 未就绪/未覆盖时的兜底值(计算表达式,禁魔法值)
2
+
3
+ export const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000
4
+ // 同名多值组合数全局上限:超限截断防打爆机器(设计 §4.2)
5
+ export const DEFAULT_MAX_EXPANSION = 20
6
+ // 全局并发上限:同时运行的执行单元数(设计 §4.1)
7
+ export const DEFAULT_MAX_CONCURRENT = 2
8
+ // 调度 tick 周期与下限(设计 §4.1)
9
+ export const DEFAULT_TICK_MS = 30 * 1000
10
+ export const MIN_TICK_MS = 5 * 1000
package/src/cron.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // cron 解析薄封装:croner 承担表达式解析与触发点计算(5/6 段通吃、时区、DST)。
2
+ // 非法表达式以业务错误抛出,文案中文原样透传给路由层。
3
+
4
+ import { Cron } from 'croner'
5
+
6
+ // 求自 from 起下一次触发点(ms epoch);表达式无未来触发点时返回 null
7
+ export function nextRunAtOf(schedule, from = new Date()) {
8
+ const job = new Cron(schedule)
9
+ const next = job.nextRun(from)
10
+ return next ? next.getTime() : null
11
+ }
12
+
13
+ // 求自 from 起下 N 次触发点(编辑表单实时预览用)
14
+ export function nextRunsOf(schedule, count, from = new Date()) {
15
+ const job = new Cron(schedule)
16
+ return job.nextRuns(count, from).map((date) => date.getTime())
17
+ }
18
+
19
+ // 表达式合法性校验:合法返回 null,非法抛业务错误
20
+ export function assertValidSchedule(schedule) {
21
+ try {
22
+ new Cron(schedule)
23
+ } catch (error) {
24
+ throw new Error('cron 表达式不合法: ' + schedule)
25
+ }
26
+ }
27
+
28
+ const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
29
+ const PAD2 = (value) => String(value).padStart(2, '0')
30
+
31
+ // 人话摘要(轻量规则覆盖常用形态,兜底回原表达式)
32
+ export function summarizeCron(schedule) {
33
+ const parts = String(schedule).trim().split(/\s+/)
34
+ if (parts.length !== 5) return schedule
35
+ const [minute, hour, day, month, weekday] = parts
36
+ const clock = (h, m) => PAD2(h) + ':' + PAD2(m)
37
+ const dayFree = day === '*' && month === '*' && weekday === '*'
38
+ if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时'
39
+ if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)'
40
+ if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟'
41
+ if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return schedule
42
+ if (month !== '*' || day !== '*') {
43
+ if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute)
44
+ return schedule
45
+ }
46
+ if (weekday === '1-5') return '工作日 ' + clock(hour, minute)
47
+ if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute)
48
+ if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute)
49
+ return schedule
50
+ }
@@ -0,0 +1,51 @@
1
+ // 环境变量同名多值展开:某名称 N 个启用值贡献 N 次,组合数 = 各多值名称值的笛卡尔积;
2
+ // DFS 按需产出前 maxExpansion 个完整组合(绝不物化指数级全量,每个产出组含全部变量),
3
+ // 截断以 truncated 标志交由调用方告警。
4
+
5
+ // 多值行(行级 multi 标志,value 为换行拼接)与同名多行两条来源路径归一到值序列;
6
+ // multi 行拆分后为空(勾选多值但未填值)回退单空值,与非 multi 空值行语义一致
7
+ // (否则该名下 0 值导致整体 0 组合,静默不执行)
8
+ function valuesOf(row) {
9
+ if (row.multi === true) {
10
+ const values = String(row.value ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '')
11
+ return values.length > 0 ? values : ['']
12
+ }
13
+ return [row.value]
14
+ }
15
+
16
+ export function expandEnvMatrix({ envs, maxExpansion }) {
17
+ const enabled = envs.filter((row) => row.enabled)
18
+ const byName = new Map()
19
+ for (const row of enabled) {
20
+ const values = byName.get(row.name) || []
21
+ values.push(...valuesOf(row))
22
+ byName.set(row.name, values)
23
+ }
24
+ const names = [...byName.keys()]
25
+
26
+ // 深度优先按字典序产出;count 达上限即整枝剪断
27
+ const combinations = []
28
+ let truncated = false
29
+ function walk(index, current) {
30
+ if (combinations.length >= maxExpansion) {
31
+ truncated = true
32
+ return
33
+ }
34
+ if (index === names.length) {
35
+ combinations.push({ ...current })
36
+ return
37
+ }
38
+ for (const value of byName.get(names[index])) {
39
+ if (combinations.length >= maxExpansion) {
40
+ truncated = true
41
+ return
42
+ }
43
+ current[names[index]] = value
44
+ walk(index + 1, current)
45
+ delete current[names[index]]
46
+ }
47
+ }
48
+ walk(0, {})
49
+
50
+ return { combinations, truncated }
51
+ }
@@ -0,0 +1,17 @@
1
+ // env-text:会话任务的 prompt 变量折叠与打码(设计 §4.2 定案:变量折叠进 prompt 文本,
2
+ // 掩码开关默认关)。变量打码与 API 列表打码同源(maskValue)。
3
+
4
+ // 值打码:长度 ≤4 全打码,否则保留前 2 后 2 中段固定 ***(不泄漏原长度)
5
+ export function maskValue(value) {
6
+ const text = String(value)
7
+ if (text.length <= 4) return '***'
8
+ return text.slice(0, 2) + '***' + text.slice(-2)
9
+ }
10
+
11
+ // 折叠:变量非空时前置变量段(明文或打码),prompt 主体随后
12
+ export function buildPromptText({ prompt, env, mask }) {
13
+ const entries = Object.entries(env || {})
14
+ if (entries.length === 0) return prompt
15
+ const lines = entries.map(([name, value]) => name + '=' + (mask ? maskValue(value) : value))
16
+ return '【环境变量】\n' + lines.join('\n') + '\n\n' + prompt
17
+ }
@@ -0,0 +1,139 @@
1
+ // executor:执行链共用模块(api 手动运行与 scheduler cron 触发同源)。
2
+ // 预占 RunRecord → 全局/任务级并发闸门(内存队列)→ 执行到终态。
3
+ // 会话任务不展开同名多值(设计 §4.2);shell 任务按启用变量笛卡尔积展开。
4
+
5
+ import { DEFAULT_MAX_EXPANSION } from './config.mjs'
6
+ import { expandEnvMatrix } from './env-expand.mjs'
7
+ import { createShellRunner } from './shell-runner.mjs'
8
+
9
+ // 会话执行通道缺失时的兜底:记录 fail 而非让执行链崩溃
10
+ const SESSION_UNAVAILABLE = {
11
+ run: async () => ({ status: 'fail', message: '会话服务不可用' }),
12
+ }
13
+
14
+ export function createExecutor({ store, logger, runner, sessionRunner, maxExpansion = DEFAULT_MAX_EXPANSION, readMaxConcurrent, readLogKeep, readMaskEnvInPrompt, logSystem }) {
15
+ const shellRunner = runner || createShellRunner({ workdirFallback: process.cwd() })
16
+ // 队列与活跃计数:许可数 = min(全局上限, 任务级收紧值);全局上限经 readMaxConcurrent 动态读
17
+ const queue = []
18
+ const activeByJob = new Map()
19
+ let activeTotal = 0
20
+
21
+ function activeOf(jobId) {
22
+ return activeByJob.get(jobId) || 0
23
+ }
24
+
25
+ function limitOf(job) {
26
+ const globalLimit = readMaxConcurrent ? readMaxConcurrent() : Infinity
27
+ const jobLimit = Number.isInteger(job.concurrency) && job.concurrency > 0 ? job.concurrency : Infinity
28
+ return Math.min(globalLimit, jobLimit)
29
+ }
30
+
31
+ function pump() {
32
+ for (let i = 0; i < queue.length;) {
33
+ const item = queue[i]
34
+ if (activeTotal >= (readMaxConcurrent ? readMaxConcurrent() : Infinity)) break
35
+ if (activeOf(item.job.id) >= limitOf(item.job)) {
36
+ i++
37
+ continue
38
+ }
39
+ queue.splice(i, 1)
40
+ activeTotal++
41
+ activeByJob.set(item.job.id, activeOf(item.job.id) + 1)
42
+ // 终态写失败不悬挂:状态留在内存,下次任意写落盘自愈
43
+ runUnit(item).catch(() => {})
44
+ }
45
+ }
46
+
47
+ async function runUnit({ job, record, env, runnerFor }) {
48
+ try {
49
+ const startedAt = Date.now()
50
+ await store.runs.update(record.runId, { status: 'running', startedAt })
51
+ const outcome = await runnerFor(job).run({
52
+ job,
53
+ env,
54
+ mask: readMaskEnvInPrompt ? readMaskEnvInPrompt() : false,
55
+ logSink: { append: (chunk) => logger.append(job.id, record.runId, chunk) },
56
+ updateRecord: (patch) => store.runs.update(record.runId, patch),
57
+ })
58
+ // 终态补记:session 附带 message/sessionId,pinned 自愈时回写新会话 id
59
+ const patch = {
60
+ status: outcome.status,
61
+ endedAt: Date.now(),
62
+ durationMs: Date.now() - startedAt,
63
+ }
64
+ if (outcome.exitCode !== undefined) patch.exitCode = outcome.exitCode
65
+ if (outcome.message !== undefined) patch.message = outcome.message
66
+ if (outcome.sessionId !== undefined) patch.sessionId = outcome.sessionId
67
+ await store.runs.update(record.runId, patch)
68
+ // 任务卡状态回填:看板状态点/耗时直接读任务行(手动与 cron 同源更新)
69
+ const jobPatch = { lastStatus: outcome.status, lastDurationMs: patch.durationMs }
70
+ if (outcome.pinnedNewId !== undefined) jobPatch.pinnedSessionId = outcome.pinnedNewId
71
+ await store.jobs.update(job.id, jobPatch)
72
+ } catch (error) {
73
+ // runner 拒绝(会话服务异常等)也必须有终态,否则运行记录永久停留 running;
74
+ // 先读当前态:try 侧已落库终态(部分写成功,如任务卡回填失败)不得覆写,
75
+ // 任务卡回填改报实际终态;补写失败经 logSystem 留痕(store 损坏时文件日志仍可观测)
76
+ const detail = error && error.message ? error.message : String(error)
77
+ const current = store.runs.get(record.runId)
78
+ const persisted = current !== undefined && current.status !== 'queued' && current.status !== 'running'
79
+ try {
80
+ if (!persisted) {
81
+ await store.runs.update(record.runId, {
82
+ status: 'fail',
83
+ endedAt: Date.now(),
84
+ message: detail,
85
+ })
86
+ }
87
+ await store.jobs.update(job.id, { lastStatus: persisted ? current.status : 'fail' })
88
+ } catch (persistError) {
89
+ // 两条路径都留痕(可观测性):未落库=数据面缺口须知晓;已落库=任务卡回填缺口
90
+ const persistDetail = persistError && persistError.message ? persistError.message : String(persistError)
91
+ if (persisted) {
92
+ logSystem?.('cron-board 运行已终态但任务卡回填失败 run=' + record.runId + ' 错误=' + persistDetail)
93
+ } else {
94
+ logSystem?.('cron-board 运行终态补写失败 run=' + record.runId + ' 原始错误=' + detail + ' 补写错误=' + persistDetail)
95
+ }
96
+ }
97
+ } finally {
98
+ activeTotal--
99
+ activeByJob.set(job.id, activeOf(job.id) - 1)
100
+ pump()
101
+ // 终态后按保留策略裁剪该任务旧日志(失败不打断执行链)
102
+ if (readLogKeep) {
103
+ await logger.prune(job.id, readLogKeep()).catch(() => {})
104
+ }
105
+ }
106
+ }
107
+
108
+ return {
109
+ // 预占记录并按闸门调度执行;返回 runIds 同步可得(执行在后台按许可推进)
110
+ async dispatch(job, trigger) {
111
+ const expansion = job.kind === 'session'
112
+ ? { combinations: [{}], truncated: false }
113
+ : expandEnvMatrix({ envs: store.envs.list(), maxExpansion })
114
+ const combinations = expansion.combinations
115
+ const runnerFor = job.kind === 'session' ? () => (sessionRunner || SESSION_UNAVAILABLE) : () => shellRunner
116
+ const records = []
117
+ for (let i = 0; i < combinations.length; i++) {
118
+ const record = await store.runs.create({ jobId: job.id, trigger, status: 'queued', logFile: joinLog(job.id) })
119
+ await store.runs.update(record.runId, { logFile: joinLog(job.id, record.runId) })
120
+ // 截断不静默:受影响运行记录直接带告警文案
121
+ if (expansion.truncated) {
122
+ await store.runs.update(record.runId, { message: '环境变量组合超出上限已截断' })
123
+ }
124
+ records.push(record)
125
+ queue.push({ job, record, env: combinations[i], runnerFor })
126
+ }
127
+ pump()
128
+ return records.map((record) => record.runId)
129
+ },
130
+ status() {
131
+ return { active: activeTotal, queued: queue.length }
132
+ },
133
+ }
134
+ }
135
+
136
+ function joinLog(jobId, runId) {
137
+ const name = runId === undefined ? 'pending' : runId + '.log'
138
+ return jobId + '/' + name
139
+ }
package/src/index.js ADDED
@@ -0,0 +1,167 @@
1
+ // dsh-cron-board Host 半区:轻量级青龙面板。环境变量集中管理 + 定时任务(cron)调度,
2
+ // 执行体两类:本地脚本(宿主进程内 spawn)与 dsh 会话任务(定时向 dsh 会话投递任务文本)。
3
+ // 模块分层:store(持久化)/ logger(日志)/ executor(执行链)/ scheduler(调度)/ api(路由)。
4
+ // timer 为软依赖(嵌套注入,缺失降级只影响自动调度);settings 缺省时读默认常量。
5
+
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ import schemastery from '@deepseek-ai/schemastery'
10
+
11
+ import { createApi } from './api.mjs'
12
+ import { DEFAULT_MAX_CONCURRENT, DEFAULT_TICK_MS, MIN_TICK_MS } from './config.mjs'
13
+ import { createExecutor } from './executor.mjs'
14
+ import { createLogger } from './logger.mjs'
15
+ import { createScheduler } from './scheduler.mjs'
16
+ import { createSessionDriver } from './session-driver.mjs'
17
+ import { createStore } from './store.mjs'
18
+
19
+ export const name = 'dsh-cron-board'
20
+
21
+ // 会话任务与路由/定时同为本体能力:sessionController 硬依赖声明交 cordis 门控,
22
+ // 服务未就绪/缺失时 fiber 不激活即整体干净禁用(apply 内同步软探测会撞异步挂载时序,mce 同款事故)
23
+ export const inject = ['webServer', 'sessionController']
24
+
25
+ // 数据目录 env 覆盖:测试注入临时目录(对齐 dsh-usage-panel 先例)
26
+ const DATA_DIR_ENV = 'DSH_CRON_BOARD_DATA_DIR'
27
+ const API_PREFIX = '/api/cron-board'
28
+ const SETTINGS_NS = 'cron-board'
29
+ const TIMER_UNAVAILABLE_REASON = '宿主定时服务不可用,自动调度已停用'
30
+ const DEFAULT_LOG_KEEP_PER_JOB = 200
31
+
32
+ // 设置 schema(schemastery 声明式):tick 周期 / 全局并发 / 日志与运行元数据保留份数 / 会话投递变量掩码
33
+ const SETTINGS_SCHEMA = schemastery.object({
34
+ tickSeconds: schemastery.number().min(MIN_TICK_MS / 1000).step(1).default(DEFAULT_TICK_MS / 1000),
35
+ maxConcurrent: schemastery.number().min(1).step(1).default(DEFAULT_MAX_CONCURRENT),
36
+ logKeepPerJob: schemastery.number().min(1).step(1).default(200),
37
+ maskEnvInPrompt: schemastery.boolean().default(false).description('会话任务投递文本中环境变量打码'),
38
+ })
39
+
40
+ export function resolveDataDir(env = process.env) {
41
+ const override = env[DATA_DIR_ENV]
42
+ if (override && override.trim() !== '') return override.trim()
43
+ return join(homedir(), '.dsh', 'cron-board')
44
+ }
45
+
46
+ export function apply(ctx, config) {
47
+ const sessionController = ctx.sessionController
48
+ const dataDir = resolveDataDir()
49
+ // 装配惰性单例:首个请求 / timer 激活触发初始化,各组件只建一次
50
+ let runtimePromise = null
51
+ // 生命周期解绑引用:惰性装配完成前给安全空实现,装配后指向真实 scheduler
52
+ const schedulerRef = { current: { dispose() {} } }
53
+ const getRuntime = () => {
54
+ if (runtimePromise === null) {
55
+ runtimePromise = (async () => {
56
+ const store = await createStore({ dir: dataDir })
57
+ const logger = createLogger({ rootDir: join(dataDir, 'logs') })
58
+ const sessionDriver = createSessionDriver({
59
+ sessionController,
60
+ agents: ctx.get('agents'),
61
+ sessionQuery: ctx.get('sessionQuery'),
62
+ })
63
+ const executor = createExecutor({
64
+ store,
65
+ logger,
66
+ sessionRunner: sessionDriver,
67
+ readMaxConcurrent,
68
+ readLogKeep: () => readNumber('logKeepPerJob', DEFAULT_LOG_KEEP_PER_JOB),
69
+ readMaskEnvInPrompt: () => readBoolean('maskEnvInPrompt', false),
70
+ logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
71
+ })
72
+ const scheduler = createScheduler({ store, executor, readTickMs })
73
+ schedulerRef.current = scheduler
74
+ await scheduler.recover()
75
+ const api = createApi({
76
+ store,
77
+ logger,
78
+ executor,
79
+ scheduler,
80
+ periodic,
81
+ logSystem: (line) => (ctx.logger && ctx.logger.warn ? ctx.logger.warn(line) : undefined),
82
+ })
83
+ return { api, scheduler }
84
+ })()
85
+ runtimePromise.catch(() => {})
86
+ }
87
+ return runtimePromise
88
+ }
89
+
90
+ const periodic = { running: false, reason: null }
91
+ let armTick = null
92
+ let disposeTick = null
93
+
94
+ function readNumber(name, fallback) {
95
+ const value = readSettingValue(name)
96
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
97
+ }
98
+ function readBoolean(name, fallback) {
99
+ const value = readSettingValue(name)
100
+ return typeof value === 'boolean' ? value : fallback
101
+ }
102
+ function readSettingValue(name) {
103
+ const settings = ctx.get('settings')
104
+ const scope = settings && settings.get ? settings.get(SETTINGS_NS) : undefined
105
+ return scope ? scope[name] : undefined
106
+ }
107
+ function readTickMs() {
108
+ return Math.max(MIN_TICK_MS, readNumber('tickSeconds', DEFAULT_TICK_MS / 1000) * 1000)
109
+ }
110
+ function readMaxConcurrent() {
111
+ return readNumber('maxConcurrent', DEFAULT_MAX_CONCURRENT)
112
+ }
113
+
114
+ ctx.inject(['timer'], (tctx) => {
115
+ if (typeof tctx.interval !== 'function') {
116
+ periodic.reason = TIMER_UNAVAILABLE_REASON
117
+ return () => {}
118
+ }
119
+ armTick = () => {
120
+ if (disposeTick) disposeTick()
121
+ disposeTick = tctx.interval(() => {
122
+ getRuntime()
123
+ .then((runtime) => runtime.scheduler.tick())
124
+ .catch((error) => {
125
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] 调度 tick 失败: ' + String(error && error.stack || error))
126
+ })
127
+ }, readTickMs())
128
+ }
129
+ armTick()
130
+ periodic.running = true
131
+ return () => {
132
+ if (disposeTick) disposeTick()
133
+ disposeTick = null
134
+ periodic.running = false
135
+ }
136
+ })
137
+
138
+ ctx.inject(['settings'], (sctx) => {
139
+ if (typeof sctx.settings.register !== 'function') return
140
+ const scope = sctx.settings.register(SETTINGS_NS, SETTINGS_SCHEMA, { base: config })
141
+ // tick 周期等设置变更即时对账(重建 interval)
142
+ if (scope && typeof scope.watch === 'function') {
143
+ scope.watch(() => { if (armTick && periodic.running) armTick() })
144
+ }
145
+ })
146
+
147
+ ctx.effect(
148
+ () => ctx.webServer.register({
149
+ kind: 'prefix',
150
+ path: API_PREFIX,
151
+ handler: async (req, res) => {
152
+ try {
153
+ const runtime = await getRuntime()
154
+ await runtime.api.handle(req, res)
155
+ } catch (error) {
156
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
157
+ res.end(JSON.stringify({ error: '操作失败(系统级错误,详见服务端日志)' }))
158
+ if (ctx.logger && ctx.logger.warn) ctx.logger.warn('[cron-board] ' + String(error && error.stack || error))
159
+ }
160
+ },
161
+ }),
162
+ 'cron-board api',
163
+ )
164
+
165
+ // defer 挂起定时器随插件生命周期回收,不遗留生命周期外触发
166
+ ctx.effect(() => () => schedulerRef.current.dispose(), 'cron-board scheduler lifecycle')
167
+ }
package/src/logger.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // logger:每次运行一个日志文件,按任务分目录 logs/<jobId>/<runId>.log;
2
+ // 裁剪按文件数(与运行记录元数据同生命周期语义),删除任务连带整目录。
3
+
4
+ import { mkdir, open, readdir, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+
7
+ const LOG_EXT = '.log'
8
+ // tail 读取安全上限:单行超长时最多读文件尾部字节窗口,防一次性读入超大日志
9
+ const TAIL_READ_BYTES = 512 * 1024
10
+
11
+ function logPath(rootDir, jobId, runId) {
12
+ return join(rootDir, jobId, runId + LOG_EXT)
13
+ }
14
+
15
+ export function createLogger({ rootDir }) {
16
+ return {
17
+ append(jobId, runId, chunk) {
18
+ const file = logPath(rootDir, jobId, runId)
19
+ return mkdir(join(rootDir, jobId), { recursive: true }).then(() => {
20
+ // stdout/stderr 合流顺序写入,追加语义
21
+ return writeFile(file, chunk, { flag: 'a', encoding: 'utf8' })
22
+ })
23
+ },
24
+ async read(jobId, runId, { tailLines } = {}) {
25
+ const file = logPath(rootDir, jobId, runId)
26
+ let text
27
+ try {
28
+ if (tailLines === undefined) {
29
+ text = await readFile(file, 'utf8')
30
+ } else {
31
+ const handle = await open(file, 'r')
32
+ try {
33
+ const { size } = await handle.stat()
34
+ const window = Math.min(size, TAIL_READ_BYTES)
35
+ const buffer = Buffer.alloc(window)
36
+ await handle.read(buffer, 0, window, size - window)
37
+ text = buffer.toString('utf8')
38
+ } finally {
39
+ await handle.close()
40
+ }
41
+ }
42
+ } catch (error) {
43
+ if (error && error.code === 'ENOENT') return ''
44
+ throw error
45
+ }
46
+ if (tailLines === undefined) return text
47
+ const lines = text.split('\n')
48
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
49
+ return lines.slice(-tailLines).join('\n') + (lines.length > 0 ? '\n' : '')
50
+ },
51
+ async clear(jobId, runId) {
52
+ // 清空 = 内容清零而非删除:文件存在性保持稳定,读路径无需区分「从未运行」与「已清空」
53
+ await truncate(logPath(rootDir, jobId, runId), 0).catch((error) => {
54
+ if (!(error && error.code === 'ENOENT')) throw error
55
+ })
56
+ },
57
+ // 按文件修改时间保留最新 keep 份(runId 为随机 UUID 与时间无关,禁用名字典序)
58
+ async prune(jobId, keep) {
59
+ const dir = join(rootDir, jobId)
60
+ let names
61
+ try {
62
+ names = await readdir(dir)
63
+ } catch (error) {
64
+ if (error && error.code === 'ENOENT') return
65
+ throw error
66
+ }
67
+ const logs = names.filter((name) => name.endsWith(LOG_EXT))
68
+ const withTime = await Promise.all(logs.map(async (name) => {
69
+ const info = await stat(join(dir, name)).catch(() => null)
70
+ return { name, mtimeMs: info ? info.mtimeMs : 0 }
71
+ }))
72
+ withTime.sort((a, b) => a.mtimeMs - b.mtimeMs)
73
+ const stale = withTime.slice(0, Math.max(0, withTime.length - keep))
74
+ for (const item of stale) {
75
+ await rm(join(dir, item.name), { force: true })
76
+ }
77
+ },
78
+ async removeJob(jobId) {
79
+ await rm(join(rootDir, jobId), { recursive: true, force: true })
80
+ },
81
+ }
82
+ }
@@ -0,0 +1,99 @@
1
+ // scheduler:单一调度循环。tick 固定四步——扫描到期、先推进 nextRunAt 再执行、
2
+ // 经执行链闸门入队、超时与收尾由执行链负责。misfire 语义:错过触发点超过一个
3
+ // tick 周期即视为停机期间错过,不补跑,记 skipped 并推进(轻量取舍,设计 §4.1)。
4
+
5
+ import { nextRunAtOf } from './cron.mjs'
6
+ import { hasWindow, inWindow, nextWindowStart } from './session-window.mjs'
7
+
8
+ const SKIPPED_MISFIRE_MESSAGE = '停机期间错过'
9
+ const SKIPPED_WINDOW_MESSAGE = '窗口外跳过'
10
+ const INTERRUPTED_MESSAGE = 'dsh 重启中断'
11
+
12
+ export function createScheduler({ store, executor, readTickMs, now = () => Date.now(), scheduleAt = (at, fn) => setTimeout(fn, Math.max(0, at - now())) }) {
13
+ // 重入闸门:上一 tick 未完成(store 写链排队/慢盘)时跳过本轮,防同一触发点双触发
14
+ let ticking = false
15
+ // defer 挂起句柄登记:dispose 统一清除,不遗留生命周期外触发
16
+ const deferredCancels = new Set()
17
+
18
+ // 重启孤儿收尾:上次进程遗留的 queued/running 统一标 interrupted,不误标失败
19
+ async function recover() {
20
+ for (const run of [...store.runs.rows]) {
21
+ if (run.status !== 'queued' && run.status !== 'running') continue
22
+ await store.runs.update(run.runId, { status: 'interrupted', endedAt: now(), message: INTERRUPTED_MESSAGE })
23
+ }
24
+ }
25
+
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
+ async function tick() {
42
+ if (ticking) return
43
+ ticking = true
44
+ try {
45
+ const nowMs = now()
46
+ const graceMs = readTickMs()
47
+ for (const job of store.jobs.list()) {
48
+ if (!job.enabled) continue
49
+ if (typeof job.nextRunAt !== 'number' || Number.isNaN(job.nextRunAt)) continue
50
+ if (job.nextRunAt > nowMs) continue
51
+ // misfire 判定须在推进前读取到期值(update 会原地改写任务对象)
52
+ const dueAt = job.nextRunAt
53
+ const misfired = nowMs - dueAt > graceMs
54
+ // 先推进再执行:执行慢或并发堵不引发重复触发
55
+ const nextAt = nextRunAtOf(job.schedule, new Date(nowMs))
56
+ await store.jobs.update(job.id, { nextRunAt: nextAt, lastRunAt: nowMs })
57
+ if (misfired) {
58
+ await store.runs.create({ jobId: job.id, trigger: 'cron', status: 'skipped', message: SKIPPED_MISFIRE_MESSAGE })
59
+ continue
60
+ }
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
+ await executor.dispatch(job, 'cron')
71
+ }
72
+ } finally {
73
+ ticking = false
74
+ }
75
+ }
76
+
77
+ function dispose() {
78
+ for (const cancel of deferredCancels) {
79
+ try { cancel() } catch { /* 句柄已自行失效即无清理需求 */ }
80
+ }
81
+ deferredCancels.clear()
82
+ }
83
+
84
+ // 下次触发预览:启用任务中最小的 nextRunAt(面板状态展示用)
85
+ function nextRunAt() {
86
+ const dueList = store.jobs
87
+ .list((job) => job.enabled && typeof job.nextRunAt === 'number' && !Number.isNaN(job.nextRunAt))
88
+ .map((job) => job.nextRunAt)
89
+ return dueList.length > 0 ? Math.min(...dueList) : null
90
+ }
91
+
92
+ return {
93
+ recover,
94
+ tick,
95
+ dispose,
96
+ status: () => executor.status(),
97
+ nextRunAt,
98
+ }
99
+ }