From c0d0177a538410dcf21da6717d4aea08dafd1f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=99=A8?= <135297162+jiangchen1245@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:58:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20dsh-scheduler=20?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E6=8F=92=E4=BB=B6=20+=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=88=AA=E5=9B=BE=E7=95=99=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugins/dsh-scheduler: cron5字段/every:N 调度、持久化到 storages、 sched_add/list/remove/run_now/toggle 五工具(host-only) - assets/user-screenshot.png: 用户提供的 Codex 参考截图 --- plugins/dsh-scheduler/README.md | 41 ++++++ plugins/dsh-scheduler/host.js | 222 ++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 plugins/dsh-scheduler/README.md create mode 100644 plugins/dsh-scheduler/host.js diff --git a/plugins/dsh-scheduler/README.md b/plugins/dsh-scheduler/README.md new file mode 100644 index 0000000..273c9d3 --- /dev/null +++ b/plugins/dsh-scheduler/README.md @@ -0,0 +1,41 @@ +# dsh-scheduler — 定时任务插件 + +Host 侧定时任务调度器。给 DeepSeek Harness 补上缺失的**定时任务**能力。 + +## 能力 + +- **调度表达式** + - cron 5 字段:`分 时 日 月 周`,支持 `*`、`*/n`、`a-b`、`a,b,c`(如 `*/10 * * * *` 每 10 分钟) + - 间隔简写:`every:30s` / `every:5m` / `every:1h` +- **动作**:任意 cmd 命令行(可指定工作目录、超时) +- **持久化**:任务存于 `%DSH_HOME%\storages\dsh-scheduler.json`,重启不丢 +- **工具**(注册给 agent 调用): + +| 工具 | 作用 | +|---|---| +| `sched_add` | 添加任务(名称/调度/命令/cwd/enabled) | +| `sched_list` | 列出任务与运行状态 | +| `sched_remove` | 按 id 删除 | +| `sched_run_now` | 立即执行一次 | +| `sched_toggle` | 启用/停用 | + +## 实现要点 + +- 宿主插件环境**没有** `fs`/`process` 全局 → 文件读写走 `shell` 服务 + node 子进程 + (`btoa` 内置做 base64 传参,避免引号转义问题) +- 每秒 tick(`ctx.timer.interval`),`ticking` 标志防重入 +- 任务执行用 `cmd /c `,默认 cwd `D:\project`,默认超时 60s +- cron 的 `nextRun` 预计算:从当前时间起逐分钟扫描(最多 7 天) + +## 文件 + +- `host.js` — 完整宿主插件源码 +- 部署方式:动态 Cordis 插件(`cordis_define` + `cordis_run`),或归档到 presets/ + +## 示例 + +``` +sched_add name="每30秒打点" schedule="every:30s" command="echo tick >> C:\Users\a1703\.dsh\logs\sched-demo.log" +sched_add name="每整点提醒" schedule="0 * * * *" command="msg * 整点提醒" +sched_list +``` diff --git a/plugins/dsh-scheduler/host.js b/plugins/dsh-scheduler/host.js new file mode 100644 index 0000000..2fb2192 --- /dev/null +++ b/plugins/dsh-scheduler/host.js @@ -0,0 +1,222 @@ +// 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 ? '启用' : '停用') + }) + }, +}