Skip to content

Commit 2e2c73f

Browse files
author
alcholiclg
committed
cron tool for ms-agent
1 parent 2583687 commit 2e2c73f

21 files changed

Lines changed: 2862 additions & 0 deletions

ms_agent/cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import argparse
22

33
from ms_agent.cli.app import AppCMD
4+
from ms_agent.cli.cron import CronCMD
45
from ms_agent.cli.run import RunCMD
56
from ms_agent.cli.ui import UICMD
67

@@ -20,6 +21,7 @@ def run_cmd():
2021
RunCMD.define_args(subparsers)
2122
AppCMD.define_args(subparsers)
2223
UICMD.define_args(subparsers)
24+
CronCMD.define_args(subparsers)
2325

2426
# unknown args will be handled in config.py
2527
args, _ = parser.parse_known_args()

ms_agent/cli/cron.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""CLI sub-commands for ms-agent cron."""
2+
from __future__ import annotations
3+
4+
import argparse
5+
import asyncio
6+
import json
7+
import os
8+
import sys
9+
10+
from .base import CLICommand
11+
12+
13+
def subparser_func(args):
14+
return CronCMD(args)
15+
16+
17+
class CronCMD(CLICommand):
18+
name = 'cron'
19+
20+
def __init__(self, args):
21+
self.args = args
22+
23+
@staticmethod
24+
def define_args(parsers: argparse.ArgumentParser):
25+
parser = parsers.add_parser(
26+
CronCMD.name,
27+
help='Manage cron (scheduled) tasks.',
28+
)
29+
sub = parser.add_subparsers(dest='cron_action', help='Cron sub-commands')
30+
31+
# start
32+
p_start = sub.add_parser('start', help='Start the cron daemon.')
33+
p_start.add_argument('--foreground', action='store_true', help='Run in foreground.')
34+
p_start.add_argument('--workspace', type=str, default=None, help='Cron workspace path.')
35+
p_start.add_argument('--env', type=str, default=None, help='Path to .env file.')
36+
37+
# stop
38+
sub.add_parser('stop', help='Stop the cron daemon.')
39+
40+
# status
41+
sub.add_parser('status', help='Show scheduler status.')
42+
43+
# tick
44+
p_tick = sub.add_parser('tick', help='Run a single scheduler tick.')
45+
p_tick.add_argument('--verbose', action='store_true')
46+
47+
# list
48+
p_list = sub.add_parser('list', help='List cron jobs.')
49+
p_list.add_argument('--all', action='store_true', help='Include disabled jobs.')
50+
p_list.add_argument('--json', dest='json_output', action='store_true', help='JSON output.')
51+
52+
# create
53+
p_create = sub.add_parser('create', help='Create a new cron job.')
54+
p_create.add_argument('schedule', type=str, help='Schedule expression.')
55+
p_create.add_argument('prompt', type=str, help='Task prompt.')
56+
p_create.add_argument('--name', type=str, default='', help='Job name.')
57+
p_create.add_argument('--project', type=str, default=None, help='Agent project path.')
58+
p_create.add_argument('--timeout', type=int, default=None, help='Timeout in seconds.')
59+
60+
# pause
61+
p_pause = sub.add_parser('pause', help='Pause a job.')
62+
p_pause.add_argument('job_id', type=str)
63+
64+
# resume
65+
p_resume = sub.add_parser('resume', help='Resume a paused job.')
66+
p_resume.add_argument('job_id', type=str)
67+
68+
# run
69+
p_run = sub.add_parser('run', help='Run a job immediately.')
70+
p_run.add_argument('job_id', type=str)
71+
72+
# remove
73+
p_remove = sub.add_parser('remove', help='Remove a job.')
74+
p_remove.add_argument('job_id', type=str)
75+
76+
# history
77+
p_hist = sub.add_parser('history', help='Show job run history.')
78+
p_hist.add_argument('job_id', type=str)
79+
p_hist.add_argument('--limit', type=int, default=10)
80+
81+
# output
82+
p_out = sub.add_parser('output', help='Show job output.')
83+
p_out.add_argument('job_id', type=str)
84+
p_out.add_argument('--last', action='store_true', help='Show latest output.')
85+
86+
# import (Phase 2: declarative jobs.d/*.yaml)
87+
sub.add_parser('import', help='Import jobs from jobs.d/*.yaml declarations.')
88+
89+
parser.set_defaults(func=subparser_func)
90+
91+
def execute(self):
92+
action = getattr(self.args, 'cron_action', None)
93+
if not action:
94+
print('Usage: ms-agent cron <command>')
95+
print('Commands: start, stop, status, tick, list, create, pause, resume, run, remove, history, output')
96+
return
97+
98+
# 'import' is a Python keyword; map to _cmd_import_jobs
99+
method_name = f'_cmd_{action}' if action != 'import' else '_cmd_import_jobs'
100+
handler = getattr(self, method_name, None)
101+
if handler:
102+
handler()
103+
else:
104+
print(f'Unknown cron command: {action}')
105+
sys.exit(1)
106+
107+
def _get_service(self):
108+
from ms_agent.config.env import Env
109+
from ms_agent.cron.service import CronService
110+
111+
env_path = getattr(self.args, 'env', None)
112+
Env.load_dotenv_into_environ(env_path)
113+
114+
workspace = getattr(self.args, 'workspace', None)
115+
return CronService(workspace=workspace)
116+
117+
def _cmd_start(self):
118+
service = self._get_service()
119+
if service.daemon_is_running():
120+
print(f'Cron daemon already running (PID {service.status().get("pid")}).')
121+
return
122+
123+
foreground = getattr(self.args, 'foreground', False)
124+
if foreground:
125+
print(f'Starting cron daemon in foreground (workspace: {service.workspace})')
126+
asyncio.run(service.run_forever())
127+
else:
128+
pid = os.fork()
129+
if pid > 0:
130+
print(f'Cron daemon started (PID {pid}, workspace: {service.workspace})')
131+
return
132+
os.setsid()
133+
asyncio.run(service.run_forever())
134+
135+
def _cmd_stop(self):
136+
service = self._get_service()
137+
if service.stop_daemon():
138+
print('Cron daemon stopped.')
139+
else:
140+
print('No running cron daemon found.')
141+
142+
def _cmd_status(self):
143+
service = self._get_service()
144+
info = service.status()
145+
daemon_running = service.daemon_is_running()
146+
print(f'Daemon running: {daemon_running}')
147+
print(f'Workspace: {info["workspace"]}')
148+
print(f'Job count: {info["job_count"]}')
149+
if daemon_running:
150+
print(f'PID: {info["pid"]}')
151+
152+
def _cmd_tick(self):
153+
service = self._get_service()
154+
count = asyncio.run(service.manual_tick())
155+
verbose = getattr(self.args, 'verbose', False)
156+
if verbose:
157+
print(f'Tick completed: {count} due job(s) processed.')
158+
elif count > 0:
159+
print(f'{count} job(s) executed.')
160+
else:
161+
print('No due jobs.')
162+
163+
def _cmd_list(self):
164+
service = self._get_service()
165+
include_all = getattr(self.args, 'all', False)
166+
json_output = getattr(self.args, 'json_output', False)
167+
jobs = service.list_jobs(include_disabled=include_all)
168+
169+
if json_output:
170+
result = []
171+
for job, state in jobs:
172+
result.append({
173+
'id': job.id,
174+
'name': job.name,
175+
'enabled': job.enabled,
176+
'status': state.status,
177+
'next_run_at': state.next_run_at,
178+
'run_count': state.run_count,
179+
})
180+
print(json.dumps(result, ensure_ascii=False, indent=2))
181+
return
182+
183+
if not jobs:
184+
print('No cron jobs found.')
185+
return
186+
187+
print(f'{"ID":<12} {"Name":<25} {"Status":<12} {"Next Run":<25} {"Runs":>5}')
188+
print('-' * 82)
189+
for job, state in jobs:
190+
name = job.name[:24] if job.name else '-'
191+
next_run = state.next_run_at or '-'
192+
print(f'{job.id:<12} {name:<25} {state.status:<12} {next_run:<25} {state.run_count:>5}')
193+
194+
def _cmd_create(self):
195+
service = self._get_service()
196+
try:
197+
job = service.create_job(
198+
schedule_str=self.args.schedule,
199+
prompt=self.args.prompt,
200+
name=getattr(self.args, 'name', ''),
201+
project=getattr(self.args, 'project', None),
202+
timeout=getattr(self.args, 'timeout', None),
203+
)
204+
print(f'Created job: {job.id} ({job.name})')
205+
except Exception as e:
206+
print(f'Error: {e}', file=sys.stderr)
207+
sys.exit(1)
208+
209+
def _cmd_pause(self):
210+
service = self._get_service()
211+
if service.pause_job(self.args.job_id):
212+
print(f'Job {self.args.job_id} paused.')
213+
else:
214+
print(f'Failed to pause job {self.args.job_id}.', file=sys.stderr)
215+
216+
def _cmd_resume(self):
217+
service = self._get_service()
218+
if service.resume_job(self.args.job_id):
219+
print(f'Job {self.args.job_id} resumed.')
220+
else:
221+
print(f'Failed to resume job {self.args.job_id}.', file=sys.stderr)
222+
223+
def _cmd_run(self):
224+
service = self._get_service()
225+
result = asyncio.run(service.run_job_now(self.args.job_id))
226+
if result is None:
227+
print(f'Job {self.args.job_id} not found.', file=sys.stderr)
228+
sys.exit(1)
229+
if result.success:
230+
print(f'Job completed successfully ({result.duration_ms}ms)')
231+
if result.output:
232+
print('--- Output ---')
233+
print(result.output)
234+
else:
235+
print(f'Job failed: {result.error}', file=sys.stderr)
236+
sys.exit(1)
237+
238+
def _cmd_remove(self):
239+
service = self._get_service()
240+
if service.delete_job(self.args.job_id):
241+
print(f'Job {self.args.job_id} removed.')
242+
else:
243+
print(f'Job {self.args.job_id} not found.', file=sys.stderr)
244+
245+
def _cmd_history(self):
246+
service = self._get_service()
247+
records = service.get_history(self.args.job_id, limit=self.args.limit)
248+
if not records:
249+
print('No history found.')
250+
return
251+
print(f'{"Run At":<22} {"Status":<8} {"Duration":>10} {"Error"}')
252+
print('-' * 65)
253+
for r in records:
254+
dur = f'{r.duration_ms}ms'
255+
err = r.error or ''
256+
print(f'{r.run_at:<22} {r.status:<8} {dur:>10} {err}')
257+
258+
def _cmd_import_jobs(self):
259+
service = self._get_service()
260+
count = service.manager.repo.import_declarative()
261+
print(f'Imported {count} job(s) from jobs.d/.')
262+
263+
def _cmd_output(self):
264+
service = self._get_service()
265+
idx = -1 if getattr(self.args, 'last', False) else -1
266+
text = service.get_output(self.args.job_id, run_index=idx)
267+
if text is None:
268+
print('No output found.', file=sys.stderr)
269+
else:
270+
print(text)

ms_agent/cron/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from ms_agent.cron.types import (
2+
CronJobSpec,
3+
CronJobState,
4+
CronSchedule,
5+
ExecutionResult,
6+
NotifySpec,
7+
RepeatSpec,
8+
RunRecord,
9+
)
10+
from ms_agent.cron.parser import parse_schedule
11+
from ms_agent.cron.service import CronService
12+
13+
__all__ = [
14+
'CronJobSpec',
15+
'CronJobState',
16+
'CronSchedule',
17+
'ExecutionResult',
18+
'NotifySpec',
19+
'RepeatSpec',
20+
'RunRecord',
21+
'parse_schedule',
22+
'CronService',
23+
]

0 commit comments

Comments
 (0)