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:
oliver 2026-09-10 15:23:42 +08:00
parent 43facd608a
commit b6934b20a3
10 changed files with 1501 additions and 53 deletions

219
test/monitor.test.js Normal file
View 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))
})

View file

@ -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/)

View file

@ -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 {} },