Files
dsh-enhancements/plugins/dsh-scheduler/host.js
T
江晨 c0d0177a53 feat: 新增 dsh-scheduler 定时任务插件 + 用户截图留档
- plugins/dsh-scheduler: cron5字段/every:N 调度、持久化到 storages、
  sched_add/list/remove/run_now/toggle 五工具(host-only)
- assets/user-screenshot.png: 用户提供的 Codex 参考截图
2026-08-17 11:58:03 +08:00

223 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// dsh-scheduler 插件 — Host 半(完整源码,与 cordis_define 提交的包一致)
// 定时任务调度器:
// - 调度表达式:cron 5字段(分 时 日 月 周)或 every:N{s|m|h}
// - 持久化:%DSH_HOME%\storages\dsh-scheduler.jsonnode 子进程读写,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: '任务 idsched_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 ? '启用' : '停用')
})
},
}