mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 00:43:22 +08:00
Initial commit: scheduled tasks plugin for DeepSeek Harness
Sidebar jobs under New Session, isolated run history, cron/at schedules, and model-facing cron_* tools.
This commit is contained in:
commit
ef8ff6e10d
22 changed files with 5711 additions and 0 deletions
230
test/tools.test.js
Normal file
230
test/tools.test.js
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { createHostService } from '../lib/host.js'
|
||||
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, scheduleFromArgs } from '../lib/tools.js'
|
||||
|
||||
async function makeService(t) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
return createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function byName(defs) {
|
||||
return Object.fromEntries(defs.map((row) => [row.name, row]))
|
||||
}
|
||||
|
||||
test('cron_create then cron_list round-trip, pause and delete', async (t) => {
|
||||
const service = await makeService(t)
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const created = await tools.cron_create.execute({
|
||||
name: '晨间摘要',
|
||||
prompt: 'Summarize overnight CI.',
|
||||
expr: '0 9 * * 1-5',
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, {})
|
||||
assert.equal(created.job.name, '晨间摘要')
|
||||
assert.equal(created.job.schedule.kind, 'cron')
|
||||
assert.equal(created.job.schedule.expr, '0 9 * * 1-5')
|
||||
assert.ok(created.job.id)
|
||||
|
||||
const listed = await tools.cron_list.execute({}, {})
|
||||
assert.equal(listed.count, 1)
|
||||
assert.equal(listed.jobs[0].id, created.job.id)
|
||||
|
||||
const paused = await tools.cron_pause.execute({ id: created.job.id }, {})
|
||||
assert.equal(paused.job.enabled, false)
|
||||
|
||||
const resumed = await tools.cron_resume.execute({ id: created.job.id }, {})
|
||||
assert.equal(resumed.job.enabled, true)
|
||||
|
||||
const deleted = await tools.cron_delete.execute({ id: created.job.id }, {})
|
||||
assert.deepEqual(deleted, { id: created.job.id, deleted: true })
|
||||
const empty = await tools.cron_list.execute({}, {})
|
||||
assert.equal(empty.count, 0)
|
||||
})
|
||||
|
||||
test('cron_create cwd defaults to the calling session workspace path', async (t) => {
|
||||
const service = await makeService(t)
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const exec = { agent: { session: { header: { cwd: '/tmp/ws-app' } } } }
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'from-chat',
|
||||
prompt: 'do the thing',
|
||||
hour: 23,
|
||||
minute: 0,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, exec)
|
||||
assert.equal(created.job.cwd, '/tmp/ws-app')
|
||||
|
||||
const explicit = await tools.cron_create.execute({
|
||||
name: 'custom-cwd',
|
||||
prompt: 'do the thing',
|
||||
hour: 23,
|
||||
minute: 1,
|
||||
timezone: 'Asia/Shanghai',
|
||||
cwd: '/tmp/isolated',
|
||||
}, exec)
|
||||
assert.equal(explicit.job.cwd, '/tmp/isolated')
|
||||
})
|
||||
|
||||
test('resolveCreateCwd prefers explicit cwd then the calling session', () => {
|
||||
assert.equal(resolveCreateCwd({ cwd: '/tmp/a' }, { agent: { session: { header: { cwd: '/ws' } } } }), '/tmp/a')
|
||||
assert.equal(callerWorkingDirectory({ agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
|
||||
assert.equal(resolveCreateCwd({}, { agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
|
||||
assert.equal(resolveCreateCwd({}, {}), '')
|
||||
})
|
||||
|
||||
test('cron_create hour+minute uses today and rejects a guessed past calendar date', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.now(),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'tonight',
|
||||
prompt: 'ping',
|
||||
hour: 23,
|
||||
minute: 34,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, {})
|
||||
assert.equal(created.job.schedule.kind, 'at')
|
||||
assert.ok(created.job.nextRunAt > Date.now() - 1000)
|
||||
|
||||
await assert.rejects(
|
||||
() => tools.cron_create.execute({
|
||||
name: 'past',
|
||||
prompt: 'ping',
|
||||
at: '2026-05-14T23:34:00+08:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, {}),
|
||||
/already in the past|Current local time/,
|
||||
)
|
||||
})
|
||||
|
||||
test('cron_create accepts at and overlapping schedule fields prefer after_minutes then hour then at', async (t) => {
|
||||
const service = await makeService(t)
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'once',
|
||||
prompt: 'ping',
|
||||
at: '2026-08-24T12:00:00+08:00',
|
||||
}, {})
|
||||
assert.equal(created.job.schedule.kind, 'at')
|
||||
|
||||
const now = Date.parse('2026-08-24T00:32:00+08:00')
|
||||
const overlapped = scheduleFromArgs({
|
||||
at: '00:33',
|
||||
hour: 0,
|
||||
minute: 33,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, now)
|
||||
assert.equal(overlapped.kind, 'at')
|
||||
assert.equal(new Date(overlapped.at).toISOString(), new Date('2026-08-24T00:33:00+08:00').toISOString())
|
||||
|
||||
const exprPlusHour = scheduleFromArgs({
|
||||
expr: '35 0 24 8 *',
|
||||
hour: 0,
|
||||
minute: 35,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, now)
|
||||
assert.equal(exprPlusHour.kind, 'at')
|
||||
assert.equal(new Date(exprPlusHour.at).toISOString(), new Date('2026-08-24T00:35:00+08:00').toISOString())
|
||||
|
||||
const exprPlusAt = scheduleFromArgs({
|
||||
expr: '0 9 * * *',
|
||||
at: '2026-08-24T12:00:00+08:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, now)
|
||||
assert.equal(exprPlusAt.kind, 'at')
|
||||
assert.equal(exprPlusAt.at, '2026-08-24T12:00:00+08:00')
|
||||
|
||||
const delay = scheduleFromArgs({ after_minutes: 1, at: '00:33', hour: 0, timezone: 'Asia/Shanghai' }, now)
|
||||
assert.equal(delay.kind, 'at')
|
||||
assert.equal(new Date(delay.at).getTime(), now + 60_000)
|
||||
})
|
||||
|
||||
test('cron_create after_minutes schedules from now even if at and hour are also sent', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.now(),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const before = Date.now()
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'in-a-minute',
|
||||
prompt: 'ping',
|
||||
after_minutes: 1,
|
||||
at: '00:33',
|
||||
hour: 0,
|
||||
minute: 33,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, {})
|
||||
assert.equal(created.job.schedule.kind, 'at')
|
||||
assert.ok(created.job.nextRunAt >= before + 50_000)
|
||||
assert.ok(created.job.nextRunAt <= before + 80_000)
|
||||
})
|
||||
|
||||
test('registerCronGuidance registers the scheduled-tasks skill only', () => {
|
||||
const skills = []
|
||||
const off = registerCronGuidance({
|
||||
skills: {
|
||||
register(skill) {
|
||||
skills.push(skill)
|
||||
return () => {
|
||||
const at = skills.indexOf(skill)
|
||||
if (at >= 0) skills.splice(at, 1)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.equal(skills[0].name, 'scheduled-tasks')
|
||||
assert.equal(skills[0].source, 'runtime')
|
||||
assert.match(skills[0].description, /定时任务/)
|
||||
assert.match(skills[0].content, /hour/)
|
||||
off()
|
||||
assert.equal(skills.length, 0)
|
||||
})
|
||||
|
||||
test('registerCronTools registers each definition and disposer unregisters', () => {
|
||||
const registered = []
|
||||
const ctx = {
|
||||
tools: {
|
||||
register(definition) {
|
||||
registered.push(definition.name)
|
||||
return () => {
|
||||
const at = registered.indexOf(definition.name)
|
||||
if (at >= 0) registered.splice(at, 1)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const off = registerCronTools(ctx, {})
|
||||
assert.deepEqual(registered, ['cron_create', 'cron_list', 'cron_pause', 'cron_resume', 'cron_delete'])
|
||||
off()
|
||||
assert.deepEqual(registered, [])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue