mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-11 16:35:47 +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
281
lib/tools.js
281
lib/tools.js
|
|
@ -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
|
||||
`,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue