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

View file

@ -38,6 +38,14 @@ const JOB_SCHEMA = {
delivery: { type: 'object', additionalProperties: true },
origin: { type: 'object', additionalProperties: true },
mirrorToSession: { type: 'boolean' },
labels: { type: 'object', additionalProperties: { type: 'string' } },
watch: { type: 'object', additionalProperties: true },
progress: { type: 'object', additionalProperties: true },
report: { type: 'object', additionalProperties: true },
persistHistory: { type: 'object', additionalProperties: true },
state: { type: 'string' },
stateEnteredAt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
stuck: { type: 'boolean' },
schedule: { type: 'object', additionalProperties: true },
createdAt: { type: 'number' },
updatedAt: { type: 'number' },
@ -48,6 +56,90 @@ const JOB_SCHEMA = {
},
}
const RUN_SCHEMA = {
type: 'object',
additionalProperties: true,
properties: {
id: { type: 'string' },
run_id: { type: 'string' },
jobId: { type: 'string' },
status: { type: 'string' },
state: { type: 'string' },
stateEnteredAt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
exitedAt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
},
}
function parseLabelsArg(raw) {
if (raw == null || raw === '') return undefined
if (typeof raw === 'object' && !Array.isArray(raw)) {
const out = {}
for (const [k, v] of Object.entries(raw)) {
if (k) out[String(k)] = String(v ?? '')
}
return out
}
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return undefined
try {
const parsed = JSON.parse(trimmed)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parseLabelsArg(parsed)
}
} catch {
// key=value,key2=value2
const out = {}
for (const part of trimmed.split(',')) {
const idx = part.indexOf('=')
if (idx <= 0) continue
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
}
return Object.keys(out).length ? out : undefined
}
}
return undefined
}
function parseJsonObjectArg(raw, fieldName) {
if (raw == null || raw === '' || raw === false) return undefined
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw.trim())
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
} catch {
const error = new Error(`${fieldName} must be a JSON object`)
error.code = 'INVALID_JOB'
throw error
}
}
const error = new Error(`${fieldName} must be an object`)
error.code = 'INVALID_JOB'
throw error
}
function monitorFieldsFromArgs(args = {}) {
const out = {}
const labels = parseLabelsArg(args.labels)
if (labels) out.labels = labels
if (args.watch !== undefined) {
const watch = parseJsonObjectArg(args.watch, 'watch')
out.watch = watch === undefined ? null : watch
}
if (args.progress !== undefined) {
const progress = parseJsonObjectArg(args.progress, 'progress')
out.progress = progress === undefined ? null : progress
}
if (args.report !== undefined) {
const report = parseJsonObjectArg(args.report, 'report')
out.report = report === undefined ? null : report
}
if (args.persist_history !== undefined) out.persist_history = args.persist_history
if (args.persistHistory !== undefined) out.persistHistory = args.persistHistory
return out
}
function text(value) {
return [{ type: 'text', text: String(value || '') }]
}
@ -109,13 +201,17 @@ function jobLine(job) {
if (!job) return ''
const tz = job.schedule?.timezone || 'Asia/Shanghai'
const when = job.nextRunAt ? formatInZone(job.nextRunAt, tz) : 'n/a'
const state = job.enabled === false ? 'paused' : 'enabled'
const life = job.state || (job.enabled === false ? 'paused' : 'enabled')
const sched = job.schedule?.kind === 'at'
? `at ${job.schedule.at}`
: (job.schedule?.expr || 'cron')
const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model'
const delivery = deliveryLine(job.delivery)
return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery} next=${when} id=${job.id}`
const labels = job.labels && Object.keys(job.labels).length
? ` labels=${JSON.stringify(job.labels)}`
: ''
const stuck = job.stuck ? ' STUCK' : ''
return `${job.name} [${life}${stuck}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery}${labels} next=${when} id=${job.id}`
}
export function callerWorkingDirectory(exec) {
@ -292,6 +388,11 @@ export function cronToolDefinitions(service, deps = {}) {
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im (Web/sidebar only; IM chats cannot retarget).' },
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
mirror_to_session: { type: 'boolean', description: 'If true, after each fire append the run summary into the origin WhatsApp/Web session as a system-reminder (no new model turn). Default false — results stay in run history (and IM delivery when configured).' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'String labels for discovery/monitoring, e.g. {"role":"worker","task":"theory","owner":"alice"}.' },
watch: { type: 'object', additionalProperties: true, description: 'Listener binding: {"taskId":"..."} and/or {"labels":{...},"match":"any|all","timeout_min":30}.' },
progress: { type: 'object', additionalProperties: true, description: 'Progress declaration: {"channel":{"file":"progress.json"},"metrics":[{"key":"done","label":"已完成"}]}.' },
report: { type: 'object', additionalProperties: true, description: 'Watcher report: {"mode":"on_change|on_complete|periodic","delivery":"dsh|im|none","interval_min":15}.' },
persist_history: { type: 'string', description: 'History policy: forever | retain:N | archive:endpoint. Default retain from settings.' },
},
required: ['name', 'prompt'],
},
@ -346,6 +447,7 @@ export function cronToolDefinitions(service, deps = {}) {
mirrorToSession: args.mirror_to_session === true,
...origin ? { origin } : {},
agentPreset,
...monitorFieldsFromArgs(args),
}, identity, { fromImPeer: !!peer?.botId })
return { job }
} catch (error) {
@ -362,12 +464,15 @@ export function cronToolDefinitions(service, deps = {}) {
},
{
name: 'cron_list',
description: 'List DSH 定时任务 jobs (sidebar scheduled jobs). Use this whenever the user asks what scheduled tasks exist. NEVER use crontab -l or /etc/cron* — those are OS crontabs, not this plugin.',
description: 'List DSH 定时任务 jobs (sidebar scheduled jobs). Filter by enabled_only, state (idle|pending|running|succeeded|failed|paused), labels, or task_id. NEVER use crontab -l.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
enabled_only: { type: 'boolean', description: 'If true, omit paused jobs.' },
state: { type: 'string', description: 'Lifecycle filter: idle|pending|running|succeeded|failed|paused.' },
task_id: { type: 'string', description: 'Exact job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label filter (all keys must match unless used with cron_query match=any).' },
},
},
output: {
@ -390,12 +495,170 @@ export function cronToolDefinitions(service, deps = {}) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
let jobs = await service.listJobs(identity)
const query = {
...parseLabelsArg(args?.labels) ? { labels: parseLabelsArg(args.labels) } : {},
...args?.task_id ? { taskId: String(args.task_id).trim() } : {},
...args?.state ? { state: String(args.state).trim() } : {},
}
let jobs = await service.listJobs(identity, Object.keys(query).length ? query : null)
if (peer?.botId) jobs = jobs.filter((job) => jobVisibleToPeer(job, peer))
if (args?.enabled_only === true) jobs = jobs.filter((job) => job.enabled !== false)
return { jobs, count: jobs.length }
},
},
{
name: 'cron_query',
description: 'Listener/monitor query: resolve jobs by taskId or labels (match any|all), optionally include run history and decode progress snapshots from progress.channel.file.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
task_id: { type: 'string', description: 'Watch a single job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label selector.' },
match: { type: 'string', description: 'Label match mode: all (default) or any.' },
state: { type: 'string', description: 'Filter projected lifecycle state.' },
include_runs: { type: 'boolean', description: 'Include run instances per job.' },
decode_progress: { type: 'boolean', description: 'Read progress.channel.file snapshots.' },
},
},
output: {
schema: {
type: 'object',
additionalProperties: true,
properties: {
jobs: { type: 'array', items: JOB_SCHEMA },
items: { type: 'array', items: { type: 'object', additionalProperties: true } },
count: { type: 'integer' },
},
},
render: (_args, value) => {
const rows = (value.jobs || []).map(jobLine)
return text(rows.length
? `cron_query (${value.count}):\n${rows.join('\n')}`
: 'cron_query: no matching jobs.')
},
},
presentCall: () => ({ card: 'generic', title: 'cron_query' }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const result = await service.queryJobs({
taskId: args?.task_id,
labels: parseLabelsArg(args?.labels),
match: args?.match === 'any' ? 'any' : 'all',
state: args?.state,
includeRuns: args?.include_runs === true,
decodeProgress: args?.decode_progress === true,
}, identity)
if (peer?.botId) {
result.jobs = result.jobs.filter((job) => jobVisibleToPeer(job, peer))
result.items = (result.items || []).filter((item) => jobVisibleToPeer(item.job, peer))
result.count = result.jobs.length
}
return result
},
},
{
name: 'cron_runs',
description: 'List run instances for a task (history). Supports state/from/to/limit/cursor. History defaults to retain:N; forever jobs keep all rows.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
task_id: { type: 'string', description: 'Job id.' },
state: { type: 'string', description: 'Filter: pending|running|succeeded|failed.' },
from: { type: 'number', description: 'Epoch ms lower bound.' },
to: { type: 'number', description: 'Epoch ms upper bound.' },
limit: { type: 'integer', description: 'Page size (max 500).' },
cursor: { type: 'string', description: 'Pagination cursor (previous run id).' },
},
required: ['task_id'],
},
output: {
schema: {
type: 'object',
additionalProperties: true,
properties: {
task_id: { type: 'string' },
runs: { type: 'array', items: RUN_SCHEMA },
next_cursor: { oneOf: [{ type: 'string' }, { type: 'null' }] },
total: { type: 'integer' },
},
},
render: (_args, value) => {
const rows = (value.runs || []).map((run) => (
`${run.run_id || run.id} ${run.state || run.status} entered=${run.stateEnteredAt || '-'} exited=${run.exitedAt || '-'}`
))
return text(rows.length
? `Runs for ${value.task_id} (${value.total}):\n${rows.join('\n')}`
: `No runs for ${value.task_id}.`)
},
},
presentCall: (args) => ({ card: 'generic', title: 'cron_runs', content: String(args?.task_id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const taskId = String(args?.task_id || '').trim()
await requireOwnedJob(service, taskId, peer, identity)
return service.listRuns(taskId, {
state: args?.state,
from: args?.from,
to: args?.to,
limit: args?.limit,
cursor: args?.cursor,
}, identity)
},
},
{
name: 'cron_progress',
description: 'Read current progress snapshots for a task_id or label watch selector (progress.channel.file).',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
task_id: { type: 'string', description: 'Job id.' },
labels: { type: 'object', additionalProperties: { type: 'string' }, description: 'Label selector (by_watch).' },
match: { type: 'string', description: 'any|all for labels.' },
},
},
output: {
schema: {
type: 'object',
additionalProperties: true,
properties: {
snapshots: { type: 'array', items: { type: 'object', additionalProperties: true } },
count: { type: 'integer' },
},
},
render: (_args, value) => {
const rows = (value.snapshots || []).map((row) => {
const metrics = row.progress?.metrics
? JSON.stringify(row.progress.metrics)
: (row.progress?.missing ? '(no file)' : '{}')
return `${row.job?.name || row.job?.id}: ${metrics}`
})
return text(rows.length ? `Progress (${value.count}):\n${rows.join('\n')}` : 'No progress snapshots.')
},
},
presentCall: () => ({ card: 'generic', title: 'cron_progress' }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const identity = resolveToolIdentity(exec, service)
const result = await service.getProgress({
taskId: args?.task_id,
labels: parseLabelsArg(args?.labels),
match: args?.match === 'any' ? 'any' : 'all',
}, identity)
if (peer?.botId) {
result.snapshots = (result.snapshots || []).filter((row) => jobVisibleToPeer(row.job, peer))
result.count = result.snapshots.length
}
return result
},
},
{
name: 'cron_pause',
description: 'Pause a scheduled task by id from cron_list / cron_create. It stays in 定时任务 but will not fire until resumed.',
@ -497,8 +760,9 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai')
'Delivery: when chatting on WhatsApp/IM, omit delivery so the job defaults to im for the current chat (group→same group, DM→same DM). A 投递目标 is reused or auto-created. On Web/DSH, default is dsh. Or pass delivery=im with im_bot_id+im_target_id.',
'Session mirror: by default the run summary is NOT injected into the origin WhatsApp/Web chat. Pass mirror_to_session=true only when the user wants follow-up context in that chat. Full tool traces always stay in run history; IM delivery (when configured) is independent.',
'Agent preset: omit agent_preset to inherit (WhatsApp chat/group preset → creating session → Host default). Pass agent_preset to pin a preset for every scheduled run.',
'Labels/monitor: pass labels={"role":"worker","task":"theory"} on workers; listeners pass watch={"taskId":"..."} or watch={"labels":{...},"match":"all"}. Use cron_query / cron_progress / cron_runs to poll state and progress.',
'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:',
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.',
'1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete / cron_query / cron_runs / cron_progress are in your tool list, call them.',
'2. If they are not listed, load skill "scheduled-tasks" via the skill tool (exact name), then retry the cron_* tools. Those tools are registered by the dsh-ops-cron plugin — they are not unlocked by skill_search/dev_tool_search (those names are obsolete).',
'3. If cron_* still return unknown tool after loading the skill, the plugin failed to register (check Host logs for unsupported JSON schema). Tell the user; do not invent crontab workarounds.',
'4. Never run crontab, never read /etc/cron*, and never say there are no tasks until cron_list has returned.',
@ -517,8 +781,11 @@ export function makeCronSkill() {
content: `${cronGuidanceText()}
Tools:
- cron_list — list jobs
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. Session mirror is off by default; pass mirror_to_session=true only if the user wants the summary injected into the origin chat.
- cron_list — list jobs (optional state/labels/task_id filters)
- cron_query — listener query by taskId or labels; optional include_runs / decode_progress
- cron_runs — run history for a task_id
- cron_progress — read progress.channel.file snapshots
- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. Delivery and agent preset auto from session, or pass delivery=im / agent_preset explicitly. Session mirror is off by default; pass mirror_to_session=true only if the user wants the summary injected into the origin chat. Optional labels/watch/progress/report/persist_history for monitor workers and listeners.
- cron_pause / cron_resume / cron_delete — by id from cron_list
`,
}