mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 07:10:45 +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
589
test/host.test.js
Normal file
589
test/host.test.js
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { adoptSessionIntoWorkspace, bindModelSelection, createHostService, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, resolveSessionPlacement, unarchiveSession, waitForAgentTurn } from '../lib/host.js'
|
||||
import { workspaceVisibleIds } from '../lib/isolation.js'
|
||||
|
||||
function createFakeAgent() {
|
||||
let status = 'idle'
|
||||
return {
|
||||
get status() { return status },
|
||||
session: {
|
||||
deriveMessages() {
|
||||
return [{ role: 'assistant', content: [{ type: 'text', text: '测试成功' }] }]
|
||||
},
|
||||
},
|
||||
followup() {
|
||||
status = 'running'
|
||||
},
|
||||
whenIdle() {
|
||||
if (status === 'idle') return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
status = 'idle'
|
||||
resolve()
|
||||
}, 15)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fakeLiveCtx(archived) {
|
||||
const agentsById = new Map()
|
||||
return {
|
||||
get(name) {
|
||||
if (name === 'agentDefaultModel') {
|
||||
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
|
||||
}
|
||||
if (name === 'agents') {
|
||||
return {
|
||||
async create({ sessionId }) {
|
||||
const agent = createFakeAgent()
|
||||
agentsById.set(sessionId, agent)
|
||||
return { agent, dispose: async () => {} }
|
||||
},
|
||||
get(id) { return agentsById.get(id) },
|
||||
}
|
||||
}
|
||||
if (name === 'workspaceRegistry') {
|
||||
return {
|
||||
async archiveSession(sessionId) { archived.push(sessionId) },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function listen(service) {
|
||||
const server = createServer((req, res) => {
|
||||
service.handleRequest(req, res).catch((error) => {
|
||||
res.writeHead(500, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ ok: false, error: String(error) }))
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
|
||||
async function jsonRequest(base, path, options = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
origin: base,
|
||||
host: new URL(base).host,
|
||||
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
const body = await response.json()
|
||||
return { status: response.status, body }
|
||||
}
|
||||
|
||||
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
|
||||
const archived = []
|
||||
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||
let sessionSeq = 0
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => clock,
|
||||
sessionPort: {
|
||||
async createAndPrompt({ job }) {
|
||||
sessionSeq += 1
|
||||
const sessionId = `sess-${job.id}-${sessionSeq}`
|
||||
return { sessionId, status: 'succeeded', summary: `ran ${job.name}` }
|
||||
},
|
||||
async archiveSession(sessionId) {
|
||||
archived.push(sessionId)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const job = await service.createJob({
|
||||
name: 'weekday briefing',
|
||||
prompt: 'Summarize overnight CI failures.',
|
||||
cwd: '/tmp/cron-workspace',
|
||||
timeoutMinutes: 15,
|
||||
schedule: { kind: 'cron', expr: '0 9 * * 1-5', timezone: 'Asia/Shanghai' },
|
||||
})
|
||||
assert.equal(job.name, 'weekday briefing')
|
||||
assert.equal(job.enabled, true)
|
||||
assert.equal(job.cwd, '/tmp/cron-workspace')
|
||||
assert.equal(job.timeoutMinutes, 15)
|
||||
assert.ok(job.nextRunAt > clock)
|
||||
|
||||
const listed = await service.listJobs()
|
||||
assert.equal(listed.length, 1)
|
||||
assert.equal(listed[0].id, job.id)
|
||||
|
||||
const result = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.ok(result.run)
|
||||
assert.ok(result.run.sessionId)
|
||||
assert.ok(['succeeded', 'running'].includes(result.run.status))
|
||||
assert.equal(archived.includes(result.run.sessionId), true, 'archive helper must be invoked')
|
||||
|
||||
const history = await service.listHistory(job.id)
|
||||
assert.ok(history.length >= 1)
|
||||
const row = history.find((item) => item.id === result.run.id)
|
||||
assert.ok(row)
|
||||
assert.equal(row.sessionId, result.run.sessionId)
|
||||
assert.ok(row.status)
|
||||
|
||||
const state = await service.snapshot()
|
||||
const visible = workspaceVisibleIds(
|
||||
[row.sessionId, 'ordinary-session'],
|
||||
state.hiddenSessionIds,
|
||||
)
|
||||
assert.deepEqual(visible, ['ordinary-session'])
|
||||
assert.ok(state.hiddenSessionIds.includes(row.sessionId))
|
||||
})
|
||||
|
||||
test('overlap skip writes a skipped history row instead of a second session', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||
let inflight = 0
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => clock,
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
inflight += 1
|
||||
return { sessionId: `live-${inflight}`, status: 'running', summary: 'started' }
|
||||
},
|
||||
async archiveSession() {},
|
||||
},
|
||||
})
|
||||
const job = await service.createJob({
|
||||
name: 'overlap-job',
|
||||
prompt: 'do the thing',
|
||||
schedule: { kind: 'at', at: '2026-08-24T12:00:00+08:00', timezone: 'Asia/Shanghai' },
|
||||
})
|
||||
const first = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.equal(first.run.status, 'running')
|
||||
const second = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.equal(second.run.status, 'skipped')
|
||||
assert.equal(second.run.reason, 'overlap')
|
||||
assert.equal(inflight, 1)
|
||||
})
|
||||
|
||||
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const archived = []
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'http-sess-1', status: 'succeeded', summary: 'via http' }
|
||||
},
|
||||
async archiveSession(sessionId) {
|
||||
archived.push(sessionId)
|
||||
},
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
|
||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'http job',
|
||||
prompt: 'ping',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||
}),
|
||||
})
|
||||
assert.equal(created.status, 200)
|
||||
assert.equal(created.body.job.name, 'http job')
|
||||
const jobId = created.body.job.id
|
||||
|
||||
const listed = await jsonRequest(http.url, '/dsh-cron-tasks/jobs')
|
||||
assert.equal(listed.body.jobs.length, 1)
|
||||
|
||||
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${jobId}/run`, { method: 'POST' })
|
||||
assert.equal(ran.status, 200)
|
||||
assert.equal(ran.body.run.sessionId, 'http-sess-1')
|
||||
assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
|
||||
assert.deepEqual(archived, ['http-sess-1'])
|
||||
|
||||
const history = await jsonRequest(http.url, '/dsh-cron-tasks/history')
|
||||
assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1'))
|
||||
const visible = await jsonRequest(http.url, '/dsh-cron-tasks/workspace-visible?id=http-sess-1&id=other')
|
||||
assert.deepEqual(visible.body.visible, ['other'])
|
||||
})
|
||||
|
||||
test('POST /runs/:id/open reveals the run session id', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const revealed = []
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'open-sess', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
async revealSession(sessionId) { revealed.push(sessionId) },
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'open-job',
|
||||
prompt: 'ping',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||
}),
|
||||
})
|
||||
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
||||
const opened = await jsonRequest(http.url, `/dsh-cron-tasks/runs/${ran.body.run.id}/open`, { method: 'POST' })
|
||||
assert.equal(opened.status, 200)
|
||||
assert.equal(opened.body.sessionId, 'open-sess')
|
||||
assert.deepEqual(revealed, ['open-sess'])
|
||||
})
|
||||
|
||||
test('bindModelSelection fills {{model}} for persona assembly', async () => {
|
||||
let assemble
|
||||
const agentCtx = {
|
||||
on(event, listener) {
|
||||
if (event === 'system-prompt/assemble') assemble = listener
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
bindModelSelection(agentCtx, { provider: 'minimax', model: 'MiniMax-M2.5' })
|
||||
const assembled = await assemble({ variables: { cwd: '/tmp' } }, {}, async () => ({ variables: { cwd: '/tmp', model: undefined } }))
|
||||
assert.equal(assembled.variables.model, 'MiniMax-M2.5')
|
||||
assert.equal(assembled.variables.provider, 'minimax')
|
||||
})
|
||||
|
||||
test('makeLiveSessionPort creates with default model and setup', async () => {
|
||||
const created = []
|
||||
let assemble
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'agentDefaultModel') {
|
||||
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
|
||||
}
|
||||
if (name === 'agents') {
|
||||
return {
|
||||
async create(options) {
|
||||
created.push(options)
|
||||
const agent = createFakeAgent()
|
||||
await options.setup?.({
|
||||
on(event, listener) {
|
||||
if (event === 'system-prompt/assemble') assemble = listener
|
||||
return () => {}
|
||||
},
|
||||
})
|
||||
return { agent, dispose: async () => {} }
|
||||
},
|
||||
get() {},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const port = makeLiveSessionPort(ctx)
|
||||
const result = await port.createAndPrompt({
|
||||
job: { name: 'model-job', timeoutMinutes: 1 },
|
||||
run: {},
|
||||
text: 'hi',
|
||||
})
|
||||
assert.ok(result.sessionId)
|
||||
assert.equal(created[0].agentOptions.model, 'deepseek-chat')
|
||||
assert.equal(created[0].agentOptions.provider, 'deepseek')
|
||||
const assembled = await assemble({ variables: {} }, {}, async () => ({ variables: { model: undefined } }))
|
||||
assert.equal(assembled.variables.model, 'deepseek-chat')
|
||||
})
|
||||
|
||||
test('resolveDefaultModel fails loud when Models has no selection', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveDefaultModel({ get: () => undefined }, { waitMs: 0 }),
|
||||
/no default model/,
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /conceal archives every run session id', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const archived = []
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 'hide-sess', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
async archiveSession(sessionId) { archived.push(sessionId) },
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'hide-job',
|
||||
prompt: 'ping',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||
}),
|
||||
})
|
||||
await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
|
||||
archived.length = 0
|
||||
const hidden = await jsonRequest(http.url, '/dsh-cron-tasks/conceal', { method: 'POST' })
|
||||
assert.equal(hidden.status, 200)
|
||||
assert.ok(archived.includes('hide-sess'))
|
||||
})
|
||||
|
||||
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const at = Date.parse('2026-08-23T14:57:00.000Z')
|
||||
let clock = at - 60_000
|
||||
let sessions = 0
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => clock,
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
sessions += 1
|
||||
return { sessionId: `at-sess-${sessions}`, status: 'succeeded', summary: 'once' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const job = await service.createJob({
|
||||
name: 'once-at',
|
||||
prompt: 'ping',
|
||||
schedule: { kind: 'at', at: '2026-08-23T22:57:00', timezone: 'Asia/Shanghai' },
|
||||
})
|
||||
assert.equal(job.nextRunAt, at)
|
||||
clock = at + 1000
|
||||
const first = await service.dispatchRun(job.id, 'schedule')
|
||||
assert.equal(first.run.status, 'succeeded')
|
||||
assert.equal(sessions, 1)
|
||||
clock = at + 15_000
|
||||
const second = await service.dispatchRun(job.id, 'schedule')
|
||||
assert.equal(second.run, null)
|
||||
assert.equal(sessions, 1)
|
||||
clock = at + 5 * 60_000
|
||||
const third = await service.tick()
|
||||
assert.equal(third.length, 0)
|
||||
assert.equal(sessions, 1)
|
||||
const history = await service.listHistory(job.id)
|
||||
const misfires = history.filter((row) => row.reason === 'misfire')
|
||||
assert.equal(misfires.length, 0)
|
||||
})
|
||||
|
||||
test('unarchiveSession drops the id from archivedSessionIds', async () => {
|
||||
const state = { workspaceIds: ['w'], archivedSessionIds: ['s1', 's2'], initialized: true }
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name !== 'workspaceRegistry') return undefined
|
||||
return {
|
||||
enqueueOperation: async (fn) => fn(),
|
||||
requireState: () => state,
|
||||
async setState(next) {
|
||||
state.workspaceIds = next.workspaceIds
|
||||
state.archivedSessionIds = next.archivedSessionIds
|
||||
state.initialized = next.initialized
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
const ok = await unarchiveSession(ctx, 's1')
|
||||
assert.equal(ok, true)
|
||||
assert.deepEqual(state.archivedSessionIds, ['s2'])
|
||||
})
|
||||
|
||||
test('resolveSessionPlacement uses recent workspace path when job cwd is empty', () => {
|
||||
const recent = { id: 'ws-recent', path: '/tmp/ws-app', async attachSession() {} }
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'workspaceRegistry') return { list: () => [recent] }
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const placed = resolveSessionPlacement(ctx, { cwd: '' })
|
||||
assert.equal(placed.cwd, '/tmp/ws-app')
|
||||
assert.equal(placed.workspace, recent)
|
||||
const explicit = resolveSessionPlacement(ctx, { cwd: '/tmp/isolated' })
|
||||
assert.equal(explicit.cwd, '/tmp/isolated')
|
||||
assert.equal(explicit.workspace, null)
|
||||
})
|
||||
|
||||
test('adoptSessionIntoWorkspace attaches a fork whose cwd matches a workspace', async () => {
|
||||
const attached = []
|
||||
const workspace = {
|
||||
id: 'ws-1',
|
||||
path: '/tmp/ws-app',
|
||||
async attachSession(sessionId) { attached.push(sessionId) },
|
||||
}
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'workspaceRegistry') {
|
||||
return {
|
||||
list: () => [workspace],
|
||||
async enqueueOperation(fn) { await fn() },
|
||||
requireState: () => ({ archivedSessionIds: ['child-1'] }),
|
||||
async setState() {},
|
||||
}
|
||||
}
|
||||
if (name === 'sessions') {
|
||||
return { get: () => ({ header: { cwd: '/tmp/ws-app' } }) }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
const result = await adoptSessionIntoWorkspace(ctx, 'child-1')
|
||||
assert.equal(result.attached, true)
|
||||
assert.deepEqual(attached, ['child-1'])
|
||||
})
|
||||
|
||||
test('listWorkspaceChoices maps registry entries to title and path', () => {
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'workspaceRegistry') {
|
||||
return {
|
||||
list: () => [
|
||||
{ id: 'ws-1', title: 'dsh-test', path: '/tmp/ws-dsh-test' },
|
||||
{ id: 'ws-2', path: '/tmp/ws-other' },
|
||||
],
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
assert.deepEqual(listWorkspaceChoices(ctx), [
|
||||
{ id: 'ws-1', title: 'dsh-test', path: '/tmp/ws-dsh-test' },
|
||||
{ id: 'ws-2', title: 'ws-other', path: '/tmp/ws-other' },
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /workspaces lists session-port choices', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
sessionPort: {
|
||||
async listWorkspaces() {
|
||||
return [{ id: 'ws-1', title: 'app', path: '/tmp/app' }]
|
||||
},
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/workspaces')
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
|
||||
})
|
||||
|
||||
test('POST /sessions/:id/adopt uses the session port', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const adopted = []
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
sessionPort: {
|
||||
async adoptSession(sessionId) {
|
||||
adopted.push(sessionId)
|
||||
return { attached: true, workspaceId: 'ws-1', cwd: '/tmp/app' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const res = await jsonRequest(http.url, '/dsh-cron-tasks/sessions/child-9/adopt', { method: 'POST' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.attached, true)
|
||||
assert.deepEqual(adopted, ['child-9'])
|
||||
})
|
||||
|
||||
test('waitForAgentTurn waits for running then idle, and fails if the agent never starts', async () => {
|
||||
const agent = createFakeAgent()
|
||||
agent.followup()
|
||||
const finished = await waitForAgentTurn(agent, { startTimeoutMs: 500, turnTimeoutMs: 500 })
|
||||
assert.equal(finished.status, 'succeeded')
|
||||
|
||||
const idle = { status: 'idle', whenIdle: async () => {} }
|
||||
const missed = await waitForAgentTurn(idle, { startTimeoutMs: 50, turnTimeoutMs: 50 })
|
||||
assert.equal(missed.status, 'failed')
|
||||
assert.equal(missed.error, 'agent_never_started')
|
||||
})
|
||||
|
||||
test('after one live-shaped dispatch, the next run-now still fires', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const archived = []
|
||||
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
sessionPort,
|
||||
})
|
||||
|
||||
const job = await service.createJob({
|
||||
name: 'live-shape',
|
||||
prompt: 'do the live-shaped thing',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * 1-5', timezone: 'Asia/Shanghai' },
|
||||
})
|
||||
const first = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.ok(first.run.sessionId)
|
||||
assert.equal(first.run.status, 'succeeded')
|
||||
assert.equal(first.run.summary, '测试成功')
|
||||
|
||||
const second = await service.dispatchRun(job.id, 'run-now')
|
||||
assert.ok(second.run)
|
||||
assert.notEqual(second.run.status, 'skipped')
|
||||
assert.equal(second.run.status, 'succeeded')
|
||||
assert.ok(second.run.sessionId)
|
||||
assert.notEqual(second.run.sessionId, first.run.sessionId)
|
||||
assert.equal(second.run.summary, '测试成功')
|
||||
|
||||
const history = await service.listHistory(job.id)
|
||||
const terminals = history.filter((row) => row.status === 'succeeded')
|
||||
assert.equal(terminals.length, 2)
|
||||
})
|
||||
|
||||
test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const archived = []
|
||||
let clock = Date.parse('2026-08-24T01:00:00.000Z')
|
||||
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => clock,
|
||||
sessionPort,
|
||||
})
|
||||
|
||||
const job = await service.createJob({
|
||||
name: 'every-minute',
|
||||
prompt: 'tick',
|
||||
schedule: { kind: 'cron', expr: '* * * * *', timezone: 'UTC' },
|
||||
})
|
||||
assert.ok(job.nextRunAt > clock)
|
||||
clock = job.nextRunAt
|
||||
const firstTick = await service.tick()
|
||||
assert.equal(firstTick.length, 1)
|
||||
assert.equal(firstTick[0].run.status, 'succeeded')
|
||||
|
||||
const afterFirst = (await service.listJobs())[0]
|
||||
clock = afterFirst.nextRunAt
|
||||
const secondTick = await service.tick()
|
||||
assert.equal(secondTick.length, 1)
|
||||
assert.equal(secondTick[0].run.status, 'succeeded')
|
||||
assert.notEqual(secondTick[0].run.sessionId, firstTick[0].run.sessionId)
|
||||
assert.notEqual(secondTick[0].run.status, 'skipped')
|
||||
})
|
||||
88
test/isolation.test.js
Normal file
88
test/isolation.test.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import {
|
||||
applyRunIsolation,
|
||||
isHiddenFromWorkspace,
|
||||
recordHiddenSession,
|
||||
shouldHideNativeWorkspaceGroup,
|
||||
shouldPromoteCronFork,
|
||||
workspaceTitleFromCronFork,
|
||||
workspaceVisibleIds,
|
||||
} from '../lib/isolation.js'
|
||||
import { extractAssistantText } from '../lib/fire.js'
|
||||
|
||||
test('recording a run marks that session id hidden from the workspace-list projection and present in history', () => {
|
||||
const sessionId = 'sess-cron-1'
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
jobId: 'job-1',
|
||||
sessionId,
|
||||
status: 'succeeded',
|
||||
scheduledAt: 1,
|
||||
actualAt: 2,
|
||||
}
|
||||
const state = applyRunIsolation({ hiddenSessionIds: [], runs: [] }, run)
|
||||
assert.equal(isHiddenFromWorkspace(sessionId, state.hiddenSessionIds), true)
|
||||
assert.ok(state.runs.some((row) => row.id === run.id && row.sessionId === sessionId))
|
||||
const visible = workspaceVisibleIds(['sess-cron-1', 'sess-human-2'], state.hiddenSessionIds)
|
||||
assert.deepEqual(visible, ['sess-human-2'])
|
||||
assert.ok(!visible.includes(sessionId))
|
||||
})
|
||||
|
||||
test('recordHiddenSession is idempotent and does not drop other hidden ids', () => {
|
||||
let state = recordHiddenSession({ hiddenSessionIds: ['a'] }, 'b')
|
||||
state = recordHiddenSession(state, 'b')
|
||||
assert.deepEqual([...state.hiddenSessionIds].sort(), ['a', 'b'])
|
||||
})
|
||||
|
||||
test('shouldHideNativeWorkspaceGroup does not hide a collapsed 未分组 that still has members', () => {
|
||||
assert.equal(shouldHideNativeWorkspaceGroup({
|
||||
sessionRowCount: 0,
|
||||
visibleSessionCount: 0,
|
||||
ungrouped: true,
|
||||
}), false)
|
||||
assert.equal(shouldHideNativeWorkspaceGroup({
|
||||
sessionRowCount: 2,
|
||||
visibleSessionCount: 0,
|
||||
ungrouped: true,
|
||||
}), true)
|
||||
assert.equal(shouldHideNativeWorkspaceGroup({
|
||||
sessionRowCount: 2,
|
||||
visibleSessionCount: 1,
|
||||
ungrouped: true,
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('workspaceTitleFromCronFork drops the scheduler prefix that hideNativeCronRows keys on', () => {
|
||||
assert.equal(workspaceTitleFromCronFork('定时任务 · 晨报 (1)'), '晨报 (1)')
|
||||
assert.equal(workspaceTitleFromCronFork('定时任务 · 晨报'), '晨报')
|
||||
assert.equal(workspaceTitleFromCronFork('普通会话'), '普通会话')
|
||||
})
|
||||
|
||||
test('shouldPromoteCronFork treats a native fork of a run session as a workspace chat', () => {
|
||||
const cronIds = new Set(['run-sess'])
|
||||
assert.equal(shouldPromoteCronFork({ id: 'run-sess', title: '定时任务 · 晨报' }, cronIds), false)
|
||||
assert.equal(shouldPromoteCronFork({
|
||||
id: 'child-sess',
|
||||
parentId: 'run-sess',
|
||||
title: '定时任务 · 晨报 (1)',
|
||||
}, cronIds), true)
|
||||
assert.equal(shouldPromoteCronFork({
|
||||
id: 'child-sess',
|
||||
title: '定时任务 · 晨报 (1)',
|
||||
}, cronIds, 'run-sess'), true)
|
||||
assert.equal(shouldPromoteCronFork({
|
||||
id: 'unrelated',
|
||||
title: '别的对话',
|
||||
}, cronIds, 'run-sess'), false)
|
||||
})
|
||||
|
||||
test('extractAssistantText returns the last assistant text, not a dispatch stub', () => {
|
||||
const text = extractAssistantText([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'say 测试成功' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '测试成功' }] },
|
||||
])
|
||||
assert.equal(text, '测试成功')
|
||||
assert.equal(extractAssistantText([]), '')
|
||||
assert.equal(extractAssistantText([{ role: 'assistant', content: [{ type: 'thinking', text: 'hmm' }] }]), '')
|
||||
})
|
||||
99
test/package.test.js
Normal file
99
test/package.test.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { test } from 'node:test'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => {
|
||||
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
||||
assert.equal(pkg.name, '@dsh-external/dsh-cron-tasks')
|
||||
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
|
||||
assert.equal(pkg.dsh.client.platform, 'web')
|
||||
assert.ok(pkg.exports['./client'])
|
||||
assert.equal(pkg.main, 'lib/index.js')
|
||||
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
|
||||
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
|
||||
assert.match(patch, /id:\s*dsh-cron-tasks/)
|
||||
const index = await readFile(join(root, 'lib/index.js'), 'utf8')
|
||||
assert.match(index, /export function apply\(/)
|
||||
assert.match(index, /registerCronTools/)
|
||||
assert.match(index, /inject\(\['tools'\]/)
|
||||
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
|
||||
assert.match(tools, /name: 'cron_create'/)
|
||||
assert.match(tools, /name: 'cron_list'/)
|
||||
assert.match(tools, /crontab/)
|
||||
assert.match(tools, /scheduled-tasks/)
|
||||
assert.match(tools, /hour/)
|
||||
assert.match(tools, /Current local time/)
|
||||
assert.match(tools, /source: 'runtime'/)
|
||||
assert.match(tools, /current session cwd/)
|
||||
assert.match(tools, /resolveCreateCwd/)
|
||||
assert.match(tools, /Working directory: if the user is chatting in a workspace/)
|
||||
assert.match(tools, /after_minutes/)
|
||||
assert.match(tools, /Extra schedule fields are ignored/)
|
||||
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
|
||||
assert.match(index, /makeCronSkill/)
|
||||
assert.doesNotMatch(index, /agent\/session-start/)
|
||||
assert.doesNotMatch(index, /system-reminder/)
|
||||
assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
|
||||
const host = await readFile(join(root, 'lib/host.js'), 'utf8')
|
||||
assert.match(host, /agentDefaultModel/)
|
||||
assert.match(host, /system-prompt\/assemble/)
|
||||
assert.match(host, /agentOptions/)
|
||||
assert.match(host, /nextRunAt === null/)
|
||||
assert.match(host, /resolveSessionPlacement/)
|
||||
assert.match(host, /adoptSessionIntoWorkspace/)
|
||||
assert.match(host, /attachLiveSessionToWorkspace/)
|
||||
assert.match(host, /listWorkspaceChoices/)
|
||||
assert.match(host, /\/workspaces/)
|
||||
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
|
||||
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
|
||||
assert.match(client, /定时任务/)
|
||||
assert.match(client, /settings\.plugin\.item/)
|
||||
assert.match(client, /新建会话/)
|
||||
assert.match(client, /logoRow/)
|
||||
assert.match(client, /dsh-ct-entry/)
|
||||
assert.match(client, /返回工作区/)
|
||||
assert.match(client, /runs\/\$\{run\.id\}\/open/)
|
||||
assert.match(client, /regionArea/)
|
||||
assert.match(client, /sessions\.open\(/)
|
||||
assert.match(client, /getService\(ctx, faces, 'sessions'\)/)
|
||||
assert.match(client, /workspaceSkin/)
|
||||
assert.match(client, /IconFolderOpen16/)
|
||||
assert.match(client, /IconProjectAddOutline16/)
|
||||
assert.match(client, /IconPlayOutline16/)
|
||||
assert.match(client, /IconPauseOutline16/)
|
||||
assert.match(client, /IconSearchOutline16/)
|
||||
assert.match(client, /searchPlaceholder/)
|
||||
assert.match(client, /\/conceal/)
|
||||
assert.match(client, /shouldPromoteCronFork/)
|
||||
assert.match(client, /workspaceTitleFromCronFork/)
|
||||
assert.match(client, /leaveCronMode/)
|
||||
assert.match(client, /fork rename failed/)
|
||||
assert.match(client, /sessions\/\$\{encodeURIComponent\(row\.id\)\}\/adopt/)
|
||||
assert.match(client, /sessions\.length > 0 && visible\.length === 0/)
|
||||
assert.match(client, /未分组|未分类/)
|
||||
assert.match(client, /expanded\[job\.id\] === true/)
|
||||
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
|
||||
assert.doesNotMatch(client, /inject = \[[^\]]*sessions[^\]]*\]/)
|
||||
assert.match(client, /工作目录/)
|
||||
assert.match(client, /cwdRecent/)
|
||||
assert.match(client, /cwdCustom/)
|
||||
assert.match(client, /CwdField/)
|
||||
assert.match(client, /api\('\/workspaces'\)/)
|
||||
assert.match(client, /atToLocalInput/)
|
||||
assert.match(client, /localInputToAt/)
|
||||
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
|
||||
assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/)
|
||||
assert.match(prd, /设置页「插件配置」里的定时任务设置/)
|
||||
assert.match(prd, /定时任务历史/)
|
||||
assert.match(prd, /chicheng-cron/)
|
||||
assert.match(prd, /dsh-automation/)
|
||||
assert.match(prd, /cronjob-dsh-plugin/)
|
||||
assert.match(prd, /dsh-cron/)
|
||||
assert.match(prd, /dsh-schedule-tasks/)
|
||||
assert.match(prd, /dsh-schedule/)
|
||||
assert.match(prd, /非目标/)
|
||||
})
|
||||
141
test/scheduler.test.js
Normal file
141
test/scheduler.test.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import {
|
||||
decideDispatch,
|
||||
formatInZone,
|
||||
nextFire,
|
||||
parseCron,
|
||||
resolveTodayAt,
|
||||
tickJobs,
|
||||
validateSchedule,
|
||||
zonedToUtc,
|
||||
} from '../lib/scheduler.js'
|
||||
|
||||
const TZ = 'Asia/Shanghai'
|
||||
|
||||
function shanghai(year, month, day, hour, minute) {
|
||||
const ms = zonedToUtc({ year, month, day, hour, minute, second: 0 }, TZ)
|
||||
assert.ok(ms != null, 'expected a real local time')
|
||||
return ms
|
||||
}
|
||||
|
||||
test('valid 5-field cron next-fire in IANA timezone', () => {
|
||||
const schedule = { kind: 'cron', expr: '0 9 * * 1-5', timezone: TZ }
|
||||
const mondayMorning = shanghai(2026, 8, 24, 8, 15)
|
||||
const next = nextFire(schedule, mondayMorning)
|
||||
const expected = shanghai(2026, 8, 24, 9, 0)
|
||||
assert.equal(next, expected)
|
||||
})
|
||||
|
||||
test('valid at next-fire is the instant when still in the future', () => {
|
||||
const at = shanghai(2026, 8, 24, 18, 30)
|
||||
const before = shanghai(2026, 8, 24, 10, 0)
|
||||
const next = nextFire({ kind: 'at', at: new Date(at).toISOString(), timezone: TZ }, before)
|
||||
assert.equal(next, at)
|
||||
const after = nextFire({ kind: 'at', at: new Date(at).toISOString(), timezone: TZ }, at)
|
||||
assert.equal(after, null)
|
||||
})
|
||||
|
||||
test('invalid cron is rejected', () => {
|
||||
assert.throws(() => parseCron('not-a-cron'), (error) => error.code === 'INVALID_CRON')
|
||||
assert.throws(() => parseCron('* * *'), (error) => error.code === 'INVALID_CRON')
|
||||
assert.throws(() => parseCron('60 9 * * *'), (error) => error.code === 'INVALID_CRON')
|
||||
assert.throws(() => parseCron('0 9 * * 8'), (error) => error.code === 'INVALID_CRON')
|
||||
})
|
||||
|
||||
test('overlap while a run is running or queued is skipped', () => {
|
||||
const now = shanghai(2026, 8, 24, 9, 0)
|
||||
const job = {
|
||||
id: 'job-overlap',
|
||||
enabled: true,
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: TZ },
|
||||
nextRunAt: now,
|
||||
createdAt: now - 86400000,
|
||||
}
|
||||
for (const status of ['running', 'queued']) {
|
||||
const decision = decideDispatch({
|
||||
job,
|
||||
runs: [{ jobId: 'job-overlap', status }],
|
||||
now,
|
||||
overlapPolicy: 'skip',
|
||||
})
|
||||
assert.equal(decision.action, 'skip')
|
||||
assert.equal(decision.reason, 'overlap')
|
||||
}
|
||||
})
|
||||
|
||||
test('host-down misfire does not enqueue a backlog of old occurrences', () => {
|
||||
const now = shanghai(2026, 8, 24, 10, 0)
|
||||
const dueHourAgo = now - 60 * 60 * 1000
|
||||
const job = {
|
||||
id: 'job-misfire',
|
||||
enabled: true,
|
||||
schedule: { kind: 'cron', expr: '* * * * *', timezone: TZ },
|
||||
nextRunAt: dueHourAgo,
|
||||
createdAt: dueHourAgo - 1000,
|
||||
}
|
||||
const decision = decideDispatch({
|
||||
job,
|
||||
runs: [],
|
||||
now,
|
||||
misfirePolicy: 'skip',
|
||||
graceMs: 60_000,
|
||||
})
|
||||
assert.equal(decision.action, 'skip')
|
||||
assert.equal(decision.reason, 'misfire')
|
||||
assert.ok(decision.nextRunAt == null || decision.nextRunAt > now - 60_000)
|
||||
|
||||
const actions = tickJobs([job], new Map([[job.id, []]]), now, {
|
||||
misfirePolicy: 'skip',
|
||||
graceMs: 60_000,
|
||||
})
|
||||
const fires = actions.filter((row) => row.action === 'fire')
|
||||
assert.equal(fires.length, 0, 'must not backfill each missed minute')
|
||||
assert.equal(actions.length, 1)
|
||||
})
|
||||
|
||||
test('resolveTodayAt uses current calendar date and rolls to tomorrow when past', () => {
|
||||
const now = shanghai(2026, 8, 23, 23, 40)
|
||||
const later = resolveTodayAt(23, 50, TZ, now)
|
||||
assert.equal(later, shanghai(2026, 8, 23, 23, 50))
|
||||
const rolled = resolveTodayAt(23, 34, TZ, now)
|
||||
assert.equal(rolled, shanghai(2026, 8, 24, 23, 34))
|
||||
})
|
||||
|
||||
test('ISO at instants display in Asia/Shanghai not as sliced UTC hours', () => {
|
||||
const at = Date.parse('2026-08-23T15:13:00.000Z')
|
||||
assert.equal(formatInZone(at, TZ), '2026-08-23 23:13 Asia/Shanghai')
|
||||
})
|
||||
|
||||
test('naive at timestamp is interpreted in the schedule timezone, not UTC', () => {
|
||||
const schedule = validateSchedule({
|
||||
kind: 'at',
|
||||
at: '2026-08-23T22:55:00',
|
||||
timezone: TZ,
|
||||
}, TZ)
|
||||
assert.equal(schedule.at, shanghai(2026, 8, 23, 22, 55))
|
||||
assert.equal(formatInZone(schedule.at, TZ), '2026-08-23 22:55 Asia/Shanghai')
|
||||
})
|
||||
|
||||
test('one-shot at fires once then waits as complete even if nextRunAt is null', () => {
|
||||
const at = shanghai(2026, 8, 23, 22, 57)
|
||||
const job = {
|
||||
id: 'at-once',
|
||||
enabled: true,
|
||||
schedule: { kind: 'at', at: new Date(at).toISOString(), timezone: TZ },
|
||||
nextRunAt: at,
|
||||
createdAt: at - 60_000,
|
||||
}
|
||||
const first = decideDispatch({ job, runs: [], now: at + 1000 })
|
||||
assert.equal(first.action, 'fire')
|
||||
assert.equal(first.nextRunAt, null)
|
||||
|
||||
const after = { ...job, nextRunAt: null, lastRunAt: at + 12_000 }
|
||||
const second = decideDispatch({
|
||||
job: after,
|
||||
runs: [{ jobId: 'at-once', status: 'succeeded', scheduledAt: at }],
|
||||
now: at + 15_000,
|
||||
})
|
||||
assert.equal(second.action, 'wait')
|
||||
assert.equal(second.reason, 'complete')
|
||||
})
|
||||
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