mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 23:20:45 +08:00
Add cron monitor APIs: labels, watch, progress, and query tools.
Extend jobs with lifecycle projection and history policies so listeners can poll state at scale without breaking existing cron_* flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
43facd608a
commit
b6934b20a3
10 changed files with 1501 additions and 53 deletions
219
test/monitor.test.js
Normal file
219
test/monitor.test.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import {
|
||||
labelsMatch,
|
||||
normalizeLabels,
|
||||
normalizePersistHistory,
|
||||
normalizeProgress,
|
||||
normalizeReport,
|
||||
normalizeWatch,
|
||||
projectJobState,
|
||||
readProgressSnapshot,
|
||||
selectJobsByWatch,
|
||||
} from '../lib/monitor.js'
|
||||
import { createJobRecord, emptyState, listHistoryPage, pruneRuns } from '../lib/store.js'
|
||||
import { claimOccurrence, publicJob } from '../lib/fire.js'
|
||||
import { createHostService } from '../lib/host.js'
|
||||
import { cronToolDefinitions } from '../lib/tools.js'
|
||||
|
||||
test('labelsMatch any/all', () => {
|
||||
const job = { role: 'worker', task: 'theory', owner: 'alice' }
|
||||
assert.equal(labelsMatch(job, { role: 'worker', task: 'theory' }, 'all'), true)
|
||||
assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'all'), false)
|
||||
assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'any'), true)
|
||||
})
|
||||
|
||||
test('normalize watch / progress / report / persist_history', () => {
|
||||
assert.deepEqual(normalizeWatch({ taskId: 'abc', timeout_min: 30 }).taskId, 'abc')
|
||||
assert.equal(normalizeWatch({ labels: { task: 'theory' }, match: 'any' }).match, 'any')
|
||||
assert.throws(() => normalizeWatch({}), { code: 'INVALID_WATCH' })
|
||||
const progress = normalizeProgress({
|
||||
channel: { file: '进度.json' },
|
||||
metrics: [{ key: 'done', label: '已完成', unit: '个' }],
|
||||
})
|
||||
assert.equal(progress.channel.kind, 'file')
|
||||
assert.equal(progress.metrics[0].key, 'done')
|
||||
assert.equal(normalizeReport({ mode: 'periodic', delivery: 'im', interval_min: 5 }).intervalMin, 5)
|
||||
assert.equal(normalizePersistHistory('forever').kind, 'forever')
|
||||
assert.equal(normalizePersistHistory('retain:50').limit, 50)
|
||||
assert.equal(normalizePersistHistory('archive:https://example/archive').endpoint, 'https://example/archive')
|
||||
})
|
||||
|
||||
test('projectJobState maps run statuses', () => {
|
||||
const job = {
|
||||
id: 'j1',
|
||||
enabled: true,
|
||||
timeoutMinutes: 10,
|
||||
schedule: { kind: 'cron', expr: '0 * * * *', timezone: 'Asia/Shanghai' },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
assert.equal(projectJobState(job, []).state, 'idle')
|
||||
assert.equal(projectJobState(job, [{ id: 'r1', status: 'queued', stateEnteredAt: 10 }]).state, 'pending')
|
||||
assert.equal(projectJobState(job, [{ id: 'r1', status: 'running', stateEnteredAt: 10 }]).state, 'running')
|
||||
assert.equal(projectJobState({ ...job, enabled: false }, []).state, 'paused')
|
||||
const stuck = projectJobState(
|
||||
job,
|
||||
[{ id: 'r1', status: 'running', stateEnteredAt: Date.now() - 2 * 60 * 60_000 }],
|
||||
Date.now(),
|
||||
)
|
||||
assert.equal(stuck.stuck, true)
|
||||
})
|
||||
|
||||
test('createJobRecord stores monitor fields; publicJob exposes state', () => {
|
||||
const state = emptyState()
|
||||
const now = Date.parse('2026-09-10T07:00:00.000Z')
|
||||
const job = createJobRecord({
|
||||
name: 'worker',
|
||||
prompt: 'work',
|
||||
schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' },
|
||||
labels: { role: 'worker', task: 'theory' },
|
||||
progress: { channel: { file: 'p.json' }, metrics: [{ key: 'done' }] },
|
||||
persist_history: 'forever',
|
||||
}, state, now)
|
||||
assert.deepEqual(job.labels, { role: 'worker', task: 'theory' })
|
||||
assert.equal(job.persistHistory.kind, 'forever')
|
||||
assert.equal(job.progress.channel.file, 'p.json')
|
||||
const view = publicJob(job, [])
|
||||
assert.equal(view.state, 'idle')
|
||||
assert.deepEqual(view.labels, job.labels)
|
||||
})
|
||||
|
||||
test('label overlap skips second worker', () => {
|
||||
const now = Date.parse('2026-09-10T07:00:00.000Z')
|
||||
let state = emptyState()
|
||||
const a = createJobRecord({
|
||||
name: 'w1',
|
||||
prompt: 'a',
|
||||
schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' },
|
||||
labels: { role: 'worker', task: 'theory' },
|
||||
}, state, now)
|
||||
const b = createJobRecord({
|
||||
name: 'w2',
|
||||
prompt: 'b',
|
||||
schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' },
|
||||
labels: { role: 'worker', task: 'theory' },
|
||||
}, state, now)
|
||||
state = { ...state, jobs: [a, b], runs: [{
|
||||
id: 'run-a',
|
||||
jobId: a.id,
|
||||
status: 'running',
|
||||
scheduledAt: now,
|
||||
actualAt: now,
|
||||
stateEnteredAt: now,
|
||||
}] }
|
||||
const claimed = claimOccurrence(state, b.id, now, 'run-now', state.settings)
|
||||
assert.equal(claimed.decision.action, 'skip')
|
||||
assert.equal(claimed.decision.reason, 'label_overlap')
|
||||
})
|
||||
|
||||
test('pruneRuns forever keeps all terminals for that job', () => {
|
||||
const jobs = [{
|
||||
id: 'j1',
|
||||
persistHistory: { kind: 'forever' },
|
||||
}, {
|
||||
id: 'j2',
|
||||
persistHistory: { kind: 'retain', limit: 10 },
|
||||
}]
|
||||
const runs = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
runs.push({ id: `a${i}`, jobId: 'j1', status: 'succeeded', actualAt: i })
|
||||
runs.push({ id: `b${i}`, jobId: 'j2', status: 'succeeded', actualAt: i })
|
||||
}
|
||||
const pruned = pruneRuns(runs, 10, jobs)
|
||||
assert.equal(pruned.filter((r) => r.jobId === 'j1').length, 30)
|
||||
assert.equal(pruned.filter((r) => r.jobId === 'j2').length, 10)
|
||||
})
|
||||
|
||||
test('listHistoryPage paginates', () => {
|
||||
const state = {
|
||||
runs: [
|
||||
{ id: 'r3', jobId: 'j', status: 'succeeded', actualAt: 30 },
|
||||
{ id: 'r2', jobId: 'j', status: 'succeeded', actualAt: 20 },
|
||||
{ id: 'r1', jobId: 'j', status: 'succeeded', actualAt: 10 },
|
||||
],
|
||||
}
|
||||
const page1 = listHistoryPage(state, 'j', { limit: 2 })
|
||||
assert.equal(page1.runs.length, 2)
|
||||
assert.equal(page1.nextCursor, 'r2')
|
||||
const page2 = listHistoryPage(state, 'j', { limit: 2, cursor: page1.nextCursor })
|
||||
assert.equal(page2.runs.length, 1)
|
||||
assert.equal(page2.runs[0].id, 'r1')
|
||||
})
|
||||
|
||||
test('cron_query / cron_progress / cron_runs tools', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-mon-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const progressPath = join(dir, 'progress.json')
|
||||
await writeFile(progressPath, JSON.stringify({
|
||||
taskId: 'x',
|
||||
state: 'running',
|
||||
updatedAt: '2026-09-10T15:00:00+08:00',
|
||||
metrics: { total: 10, done: 3, percent: 30 },
|
||||
}), 'utf8')
|
||||
|
||||
const service = createHostService({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-09-10T07:00:00.000Z'),
|
||||
sessionPort: {
|
||||
async createAndPrompt() {
|
||||
return { sessionId: 's1', status: 'succeeded', summary: 'ok' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const tools = Object.fromEntries(cronToolDefinitions(service).map((row) => [row.name, row]))
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'theory-worker',
|
||||
prompt: 'do theory',
|
||||
after_minutes: 5,
|
||||
labels: { role: 'worker', task: 'theory' },
|
||||
progress: { channel: { file: progressPath }, metrics: [{ key: 'done' }, { key: 'total' }] },
|
||||
persist_history: 'forever',
|
||||
cwd: dir,
|
||||
}, { agent: { session: { header: { cwd: dir } } } })
|
||||
assert.equal(created.job.labels.role, 'worker')
|
||||
assert.equal(created.job.state, 'idle')
|
||||
|
||||
const watcher = await tools.cron_create.execute({
|
||||
name: 'theory-watch',
|
||||
prompt: 'watch workers via cron_query',
|
||||
expr: '*/5 * * * *',
|
||||
watch: { labels: { task: 'theory' }, match: 'all' },
|
||||
report: { mode: 'on_complete', delivery: 'none' },
|
||||
cwd: dir,
|
||||
}, { agent: { session: { header: { cwd: dir } } } })
|
||||
assert.equal(watcher.job.watch.labels.task, 'theory')
|
||||
|
||||
const queried = await tools.cron_query.execute({
|
||||
labels: { task: 'theory' },
|
||||
match: 'all',
|
||||
decode_progress: true,
|
||||
}, {})
|
||||
assert.ok(queried.count >= 1)
|
||||
assert.ok(queried.items.some((item) => item.progress?.metrics?.done === 3))
|
||||
|
||||
const listed = await tools.cron_list.execute({ labels: { role: 'worker' }, state: 'idle' }, {})
|
||||
assert.ok(listed.jobs.some((job) => job.id === created.job.id))
|
||||
|
||||
await service.dispatchRun(created.job.id, 'run-now')
|
||||
const runs = await tools.cron_runs.execute({ task_id: created.job.id, limit: 10 }, {})
|
||||
assert.ok(runs.runs.length >= 1)
|
||||
assert.ok(runs.runs[0].stateEnteredAt)
|
||||
|
||||
const progress = await tools.cron_progress.execute({ task_id: created.job.id }, {})
|
||||
assert.equal(progress.snapshots[0].progress.metrics.done, 3)
|
||||
|
||||
assert.deepEqual(
|
||||
selectJobsByWatch([created.job, watcher.job], { labels: { task: 'theory' } }).map((j) => j.name),
|
||||
['theory-worker'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
selectJobsByWatch([created.job, watcher.job], watcher.job.watch).map((j) => j.name),
|
||||
['theory-worker'],
|
||||
)
|
||||
assert.deepEqual(normalizeLabels({ a: 1 }), { a: '1' })
|
||||
assert.ok(await readProgressSnapshot(created.job))
|
||||
})
|
||||
|
|
@ -24,6 +24,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
|
|||
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, /name: 'cron_query'/)
|
||||
assert.match(tools, /name: 'cron_runs'/)
|
||||
assert.match(tools, /name: 'cron_progress'/)
|
||||
assert.match(tools, /crontab/)
|
||||
assert.match(tools, /scheduled-tasks/)
|
||||
assert.match(tools, /hour/)
|
||||
|
|
|
|||
|
|
@ -431,7 +431,16 @@ test('registerCronTools registers each definition and disposer unregisters', ()
|
|||
},
|
||||
}
|
||||
const off = registerCronTools(ctx, {})
|
||||
assert.deepEqual(registered, ['cron_create', 'cron_list', 'cron_pause', 'cron_resume', 'cron_delete'])
|
||||
assert.deepEqual(registered, [
|
||||
'cron_create',
|
||||
'cron_list',
|
||||
'cron_query',
|
||||
'cron_runs',
|
||||
'cron_progress',
|
||||
'cron_pause',
|
||||
'cron_resume',
|
||||
'cron_delete',
|
||||
])
|
||||
off()
|
||||
assert.deepEqual(registered, [])
|
||||
})
|
||||
|
|
@ -440,6 +449,9 @@ test('cron tool output schemas never use type arrays (Host rejects them)', () =>
|
|||
const defs = cronToolDefinitions({
|
||||
async createJob() { return {} },
|
||||
async listJobs() { return [] },
|
||||
async queryJobs() { return { jobs: [], items: [], count: 0 } },
|
||||
async listRuns() { return { task_id: '', runs: [], next_cursor: null, total: 0 } },
|
||||
async getProgress() { return { snapshots: [], count: 0 } },
|
||||
async pauseJob() { return {} },
|
||||
async resumeJob() { return {} },
|
||||
async deleteJob() { return {} },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue