- plugins/dsh-scheduler: cron5字段/every:N 调度、持久化到 storages、 sched_add/list/remove/run_now/toggle 五工具(host-only) - assets/user-screenshot.png: 用户提供的 Codex 参考截图
223 lines
9.4 KiB
JavaScript
223 lines
9.4 KiB
JavaScript
// dsh-scheduler 插件 — Host 半(完整源码,与 cordis_define 提交的包一致)
|
||
// 定时任务调度器:
|
||
// - 调度表达式:cron 5字段(分 时 日 月 周)或 every:N{s|m|h}
|
||
// - 持久化:%DSH_HOME%\storages\dsh-scheduler.json(node 子进程读写,btoa 编码)
|
||
// - 每秒 tick 检查到期任务,用 cmd /c 执行命令行
|
||
// - 工具:sched_add / sched_list / sched_remove / sched_run_now / sched_toggle
|
||
// 说明:宿主插件环境无 fs/process 全局,文件 IO 走 shell+node 子进程。
|
||
return {
|
||
inject: ['shell', 'timer'],
|
||
async apply(ctx) {
|
||
const readScript = [
|
||
"const fs=require('node:fs');const os=require('node:os');const path=require('node:path');",
|
||
"const home=process.env.DSH_HOME||path.join(os.homedir(),'.dsh');",
|
||
"const f=path.join(home,'storages','dsh-scheduler.json');",
|
||
"try{console.log(fs.readFileSync(f,'utf8'))}catch(e){console.log('[]')}",
|
||
].join('\n')
|
||
const writeScript = [
|
||
"const fs=require('node:fs');const os=require('node:os');const path=require('node:path');",
|
||
"const home=process.env.DSH_HOME||path.join(os.homedir(),'.dsh');",
|
||
"const f=path.join(home,'storages','dsh-scheduler.json');",
|
||
"const d=Buffer.from(process.argv[1],'base64').toString('utf8');",
|
||
"fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,d);",
|
||
].join('\n')
|
||
|
||
let tasks = []
|
||
const load = async () => {
|
||
try {
|
||
const spec = ctx.shell.resolve({ command: 'node', args: ['-e', readScript] })
|
||
const res = await ctx.shell.run(spec)
|
||
const parsed = JSON.parse(String(res.stdout || '').trim())
|
||
tasks = Array.isArray(parsed) ? parsed : []
|
||
} catch (e) { tasks = [] }
|
||
}
|
||
const save = async () => {
|
||
try {
|
||
const b64 = btoa(JSON.stringify(tasks))
|
||
const spec = ctx.shell.resolve({ command: 'node', args: ['-e', writeScript, b64] })
|
||
await ctx.shell.run(spec)
|
||
} catch (e) { console.error('scheduler save failed: ' + String((e && e.message) || e)) }
|
||
}
|
||
|
||
const fieldMatch = (field, v) => {
|
||
if (field === '*' || field === '') return true
|
||
const toks = String(field).split(',')
|
||
for (const tok of toks) {
|
||
const m = tok.match(/^(\d+|\*)(?:-(\d+))?(?:\/(\d+))?$/)
|
||
if (!m) continue
|
||
const step = m[3] ? parseInt(m[3], 10) : 1
|
||
if (m[1] === '*') { if (v % step === 0) return true; continue }
|
||
const start = parseInt(m[1], 10)
|
||
const end = m[2] ? parseInt(m[2], 10) : start
|
||
if (v >= start && v <= end && (v - start) % step === 0) return true
|
||
}
|
||
return false
|
||
}
|
||
const cronMatch = (expr, d) => {
|
||
const p = String(expr).trim().split(/\s+/)
|
||
if (p.length !== 5) return false
|
||
return fieldMatch(p[0], d.getMinutes()) && fieldMatch(p[1], d.getHours()) &&
|
||
fieldMatch(p[2], d.getDate()) && fieldMatch(p[3], d.getMonth() + 1) && fieldMatch(p[4], d.getDay())
|
||
}
|
||
const nextCron = (expr, from) => {
|
||
const d = new Date(from)
|
||
d.setSeconds(0, 0)
|
||
for (let i = 0; i < 60 * 24 * 7; i++) {
|
||
if (cronMatch(expr, d)) return d.getTime()
|
||
d.setMinutes(d.getMinutes() + 1)
|
||
}
|
||
return null
|
||
}
|
||
const parseSchedule = (schedule) => {
|
||
const m = String(schedule).trim().match(/^every:(\d+)(s|m|h)$/)
|
||
if (m) {
|
||
const n = parseInt(m[1], 10)
|
||
const unit = m[2]
|
||
const ms = unit === 's' ? n * 1000 : unit === 'm' ? n * 60000 : n * 3600000
|
||
return { type: 'every', everyMs: ms, cronExpr: null }
|
||
}
|
||
const parts = String(schedule).trim().split(/\s+/)
|
||
if (parts.length === 5) return { type: 'cron', everyMs: null, cronExpr: parts.join(' ') }
|
||
return null
|
||
}
|
||
|
||
const runTask = async (t) => {
|
||
try {
|
||
const spec = ctx.shell.resolve({
|
||
command: 'cmd',
|
||
args: ['/c', t.command],
|
||
cwd: t.cwd || 'D:\\project',
|
||
timeoutMs: t.timeoutMs || 60000,
|
||
})
|
||
const res = await ctx.shell.run(spec)
|
||
t.lastStatus = res.code === 0 ? 'ok' : 'error'
|
||
t.lastOutput = String(res.stdout || '').slice(0, 400)
|
||
t.lastError = res.code === 0 ? null : String(res.stderr || '').slice(0, 400)
|
||
} catch (e) {
|
||
t.lastStatus = 'error'
|
||
t.lastError = String((e && e.message) || e).slice(0, 400)
|
||
}
|
||
save()
|
||
}
|
||
|
||
await load()
|
||
for (const t of tasks) {
|
||
const sch = parseSchedule(t.schedule)
|
||
if (sch) { t.scheduleType = sch.type; t.everyMs = sch.everyMs; t.cronExpr = sch.cronExpr }
|
||
if (t.nextRun === undefined || t.nextRun === null) {
|
||
t.nextRun = t.scheduleType === 'cron' ? nextCron(t.cronExpr, Date.now()) : Date.now()
|
||
}
|
||
}
|
||
save()
|
||
|
||
let ticking = false
|
||
ctx.timer.interval(async () => {
|
||
if (ticking) return
|
||
ticking = true
|
||
try {
|
||
const now = Date.now()
|
||
let changed = false
|
||
for (const t of tasks) {
|
||
if (!t.enabled) continue
|
||
let due = false
|
||
if (t.scheduleType === 'every') {
|
||
due = (t.lastRun === null || now - t.lastRun >= t.everyMs)
|
||
if (due) t.nextRun = now + t.everyMs
|
||
} else if (t.scheduleType === 'cron') {
|
||
if (t.nextRun === null || t.nextRun === undefined) t.nextRun = nextCron(t.cronExpr, now)
|
||
if (t.nextRun !== null && now >= t.nextRun) {
|
||
due = true
|
||
t.nextRun = nextCron(t.cronExpr, now + 60000)
|
||
}
|
||
}
|
||
if (due) {
|
||
t.lastRun = now
|
||
t.runs = (t.runs || 0) + 1
|
||
t.lastStatus = 'running'
|
||
changed = true
|
||
runTask(t)
|
||
}
|
||
}
|
||
if (changed) save()
|
||
} finally { ticking = false }
|
||
}, 1000)
|
||
|
||
const tool = (name, description, parameters, execute) => harness.registerTool(ctx, harness.defineTool({
|
||
name, description, parameters,
|
||
output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: String(v) }] } },
|
||
async execute(args) {
|
||
try { return await execute(args) } catch (e) { return '错误: ' + String((e && e.message) || e) }
|
||
},
|
||
}))
|
||
|
||
tool('sched_add', '添加一个定时任务。schedule 支持 cron 5字段(分 时 日 月 周,如 */10 * * * * 表示每10分钟)或 every:N{s|m|h}(如 every:30s、every:5m、every:1h)。command 为要执行的命令行(cmd 语法),默认工作目录 D:\\project,默认超时60秒。', {
|
||
name: { type: 'string', required: true, description: '任务名称' },
|
||
schedule: { type: 'string', required: true, description: '调度表达式:cron 5字段或 every:N{s|m|h}' },
|
||
command: { type: 'string', required: true, description: '要执行的命令行' },
|
||
}, async (args) => {
|
||
const sch = parseSchedule(args.schedule)
|
||
if (!sch) return '无效的调度表达式: ' + args.schedule
|
||
const t = {
|
||
id: 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||
name: args.name,
|
||
schedule: args.schedule,
|
||
scheduleType: sch.type,
|
||
everyMs: sch.everyMs,
|
||
cronExpr: sch.cronExpr,
|
||
command: args.command,
|
||
cwd: args.cwd || 'D:\\project',
|
||
enabled: args.enabled !== false,
|
||
lastRun: null, runs: 0, lastStatus: null, lastOutput: null, lastError: null,
|
||
nextRun: sch.type === 'every' ? Date.now() : nextCron(sch.cronExpr, Date.now()),
|
||
createdAt: Date.now(),
|
||
}
|
||
tasks.push(t)
|
||
save()
|
||
return '已添加任务 ' + t.id + '(' + t.name + '),下次运行: ' + (t.nextRun ? new Date(t.nextRun).toLocaleString() : '未排定')
|
||
})
|
||
|
||
tool('sched_list', '列出所有定时任务及其状态。', {}, async () => {
|
||
return JSON.stringify(tasks.map((t) => ({
|
||
id: t.id, name: t.name, schedule: t.schedule, enabled: t.enabled,
|
||
runs: t.runs, lastStatus: t.lastStatus,
|
||
lastRun: t.lastRun ? new Date(t.lastRun).toISOString() : null,
|
||
nextRun: t.nextRun ? new Date(t.nextRun).toISOString() : null,
|
||
command: t.command,
|
||
lastOutput: t.lastOutput || null, lastError: t.lastError || null,
|
||
})), null, 2)
|
||
})
|
||
|
||
tool('sched_remove', '按 id 删除一个定时任务。', {
|
||
id: { type: 'string', required: true, description: '任务 id(sched_list 查看)' },
|
||
}, async (args) => {
|
||
const before = tasks.length
|
||
tasks = tasks.filter((t) => t.id !== args.id)
|
||
if (tasks.length === before) return '未找到任务 ' + args.id
|
||
save()
|
||
return '已删除任务 ' + args.id
|
||
})
|
||
|
||
tool('sched_run_now', '立即执行一个定时任务(不等调度)。', {
|
||
id: { type: 'string', required: true, description: '任务 id' },
|
||
}, async (args) => {
|
||
const t = tasks.find((x) => x.id === args.id)
|
||
if (!t) return '未找到任务 ' + args.id
|
||
t.lastRun = Date.now(); t.runs = (t.runs || 0) + 1; t.lastStatus = 'running'
|
||
save()
|
||
runTask(t)
|
||
return '任务 ' + args.id + '(' + t.name + ')已触发执行'
|
||
})
|
||
|
||
tool('sched_toggle', '启用或停用一个定时任务。', {
|
||
id: { type: 'string', required: true, description: '任务 id' },
|
||
enabled: { type: 'boolean', required: true, description: 'true 启用 / false 停用' },
|
||
}, async (args) => {
|
||
const t = tasks.find((x) => x.id === args.id)
|
||
if (!t) return '未找到任务 ' + args.id
|
||
t.enabled = !!args.enabled
|
||
save()
|
||
return '任务 ' + args.id + ' 已' + (t.enabled ? '启用' : '停用')
|
||
})
|
||
},
|
||
}
|