fix crontab task (#19)

This commit is contained in:
paisley
2026-02-09 18:59:21 +08:00
committed by GitHub
Unverified
parent 19ae5b0f75
commit 905b828e9b
2 changed files with 90 additions and 57 deletions

View File

@@ -150,11 +150,11 @@ function transformCronJob(job: GatewayCronJob) {
// Build lastRun from state // Build lastRun from state
const lastRun = job.state?.lastRunAtMs const lastRun = job.state?.lastRunAtMs
? { ? {
time: new Date(job.state.lastRunAtMs).toISOString(), time: new Date(job.state.lastRunAtMs).toISOString(),
success: job.state.lastStatus === 'ok', success: job.state.lastStatus === 'ok',
error: job.state.lastError, error: job.state.lastError,
duration: job.state.lastDurationMs, duration: job.state.lastDurationMs,
} }
: undefined; : undefined;
// Build nextRun from state // Build nextRun from state
@@ -208,6 +208,12 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
}) => { }) => {
try { try {
// Transform frontend input to Gateway cron.add format // Transform frontend input to Gateway cron.add format
// For Discord, the recipient must be prefixed with "channel:" or "user:"
const recipientId = input.target.channelId;
const deliveryTo = input.target.channelType === 'discord' && recipientId
? `channel:${recipientId}`
: recipientId;
const gatewayInput = { const gatewayInput = {
name: input.name, name: input.name,
schedule: { kind: 'cron', expr: input.schedule }, schedule: { kind: 'cron', expr: input.schedule },
@@ -218,6 +224,7 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
delivery: { delivery: {
mode: 'announce', mode: 'announce',
channel: input.target.channelType, channel: input.target.channelType,
to: deliveryTo,
}, },
}; };
const result = await gatewayManager.rpc('cron.add', gatewayInput); const result = await gatewayManager.rpc('cron.add', gatewayInput);

View File

@@ -3,13 +3,13 @@
* Manage scheduled tasks * Manage scheduled tasks
*/ */
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { import {
Plus, Plus,
Clock, Clock,
Play, Play,
Pause, Pause,
Trash2, Trash2,
Edit, Edit,
RefreshCw, RefreshCw,
X, X,
Calendar, Calendar,
@@ -90,12 +90,12 @@ function parseCronSchedule(schedule: unknown): string {
function parseCronExpr(cron: string): string { function parseCronExpr(cron: string): string {
const preset = schedulePresets.find((p) => p.value === cron); const preset = schedulePresets.find((p) => p.value === cron);
if (preset) return preset.label; if (preset) return preset.label;
const parts = cron.split(' '); const parts = cron.split(' ');
if (parts.length !== 5) return cron; if (parts.length !== 5) return cron;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts; const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return 'Every minute'; if (minute === '*' && hour === '*') return 'Every minute';
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`; if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
if (hour === '*' && minute === '0') return 'Every hour'; if (hour === '*' && minute === '0') return 'Every hour';
@@ -109,7 +109,7 @@ function parseCronExpr(cron: string): string {
if (hour !== '*') { if (hour !== '*') {
return `Daily at ${hour}:${minute.padStart(2, '0')}`; return `Daily at ${hour}:${minute.padStart(2, '0')}`;
} }
return cron; return cron;
} }
@@ -123,7 +123,7 @@ interface TaskDialogProps {
function TaskDialog({ job, onClose, onSave }: TaskDialogProps) { function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const { channels } = useChannelsStore(); const { channels } = useChannelsStore();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [name, setName] = useState(job?.name || ''); const [name, setName] = useState(job?.name || '');
const [message, setMessage] = useState(job?.message || ''); const [message, setMessage] = useState(job?.message || '');
// Extract cron expression string from CronSchedule object or use as-is if string // Extract cron expression string from CronSchedule object or use as-is if string
@@ -140,10 +140,12 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const [customSchedule, setCustomSchedule] = useState(''); const [customSchedule, setCustomSchedule] = useState('');
const [useCustom, setUseCustom] = useState(false); const [useCustom, setUseCustom] = useState(false);
const [channelId, setChannelId] = useState(job?.target.channelId || ''); const [channelId, setChannelId] = useState(job?.target.channelId || '');
const [discordChannelId, setDiscordChannelId] = useState('');
const [enabled, setEnabled] = useState(job?.enabled ?? true); const [enabled, setEnabled] = useState(job?.enabled ?? true);
const selectedChannel = channels.find((c) => c.id === channelId); const selectedChannel = channels.find((c) => c.id === channelId);
const isDiscord = selectedChannel?.type === 'discord';
const handleSubmit = async () => { const handleSubmit = async () => {
if (!name.trim()) { if (!name.trim()) {
toast.error('Please enter a task name'); toast.error('Please enter a task name');
@@ -157,22 +159,32 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
toast.error('Please select a channel'); toast.error('Please select a channel');
return; return;
} }
// Validate Discord channel ID when Discord is selected
if (selectedChannel?.type === 'discord' && !discordChannelId.trim()) {
toast.error('Please enter a Discord Channel ID');
return;
}
const finalSchedule = useCustom ? customSchedule : schedule; const finalSchedule = useCustom ? customSchedule : schedule;
if (!finalSchedule.trim()) { if (!finalSchedule.trim()) {
toast.error('Please select or enter a schedule'); toast.error('Please select or enter a schedule');
return; return;
} }
setSaving(true); setSaving(true);
try { try {
// For Discord, use the manually entered channel ID; for others, use empty
const actualChannelId = selectedChannel!.type === 'discord'
? discordChannelId.trim()
: '';
await onSave({ await onSave({
name: name.trim(), name: name.trim(),
message: message.trim(), message: message.trim(),
schedule: finalSchedule, schedule: finalSchedule,
target: { target: {
channelType: selectedChannel!.type, channelType: selectedChannel!.type,
channelId: selectedChannel!.id, channelId: actualChannelId,
channelName: selectedChannel!.name, channelName: selectedChannel!.name,
}, },
enabled, enabled,
@@ -185,7 +197,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={onClose}> <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={onClose}>
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}> <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
@@ -209,7 +221,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
{/* Message */} {/* Message */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="message">Message / Prompt</Label> <Label htmlFor="message">Message / Prompt</Label>
@@ -221,7 +233,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
rows={3} rows={3}
/> />
</div> </div>
{/* Schedule */} {/* Schedule */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Schedule</Label> <Label>Schedule</Label>
@@ -258,7 +270,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
{useCustom ? 'Use presets' : 'Use custom cron'} {useCustom ? 'Use presets' : 'Use custom cron'}
</Button> </Button>
</div> </div>
{/* Target Channel */} {/* Target Channel */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Target Channel</Label> <Label>Target Channel</Label>
@@ -284,7 +296,21 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
</div> </div>
)} )}
</div> </div>
{/* Discord Channel ID - only shown when Discord is selected */}
{isDiscord && (
<div className="space-y-2">
<Label>Discord Channel ID</Label>
<Input
value={discordChannelId}
onChange={(e) => setDiscordChannelId(e.target.value)}
placeholder="e.g., 1438452657525100686"
/>
<p className="text-xs text-muted-foreground">
Right-click the Discord channel Copy Channel ID
</p>
</div>
)}
{/* Enabled */} {/* Enabled */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -295,7 +321,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
</div> </div>
<Switch checked={enabled} onCheckedChange={setEnabled} /> <Switch checked={enabled} onCheckedChange={setEnabled} />
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
@@ -332,7 +358,7 @@ interface CronJobCardProps {
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) { function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
const [triggering, setTriggering] = useState(false); const [triggering, setTriggering] = useState(false);
const handleTrigger = async () => { const handleTrigger = async () => {
setTriggering(true); setTriggering(true);
try { try {
@@ -345,13 +371,13 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
setTriggering(false); setTriggering(false);
} }
}; };
const handleDelete = () => { const handleDelete = () => {
if (confirm('Are you sure you want to delete this task?')) { if (confirm('Are you sure you want to delete this task?')) {
onDelete(); onDelete();
} }
}; };
return ( return (
<Card className={cn( <Card className={cn(
'transition-colors', 'transition-colors',
@@ -362,8 +388,8 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
'rounded-full p-2', 'rounded-full p-2',
job.enabled job.enabled
? 'bg-green-100 dark:bg-green-900/30' ? 'bg-green-100 dark:bg-green-900/30'
: 'bg-muted' : 'bg-muted'
)}> )}>
<Clock className={cn( <Clock className={cn(
@@ -398,14 +424,14 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
{job.message} {job.message}
</p> </p>
</div> </div>
{/* Metadata */} {/* Metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{CHANNEL_ICONS[job.target.channelType]} {CHANNEL_ICONS[job.target.channelType]}
{job.target.channelName} {job.target.channelName}
</span> </span>
{job.lastRun && ( {job.lastRun && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
@@ -417,7 +443,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
)} )}
</span> </span>
)} )}
{job.nextRun && job.enabled && ( {job.nextRun && job.enabled && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@@ -425,7 +451,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
</span> </span>
)} )}
</div> </div>
{/* Last Run Error */} {/* Last Run Error */}
{job.lastRun && !job.lastRun.success && job.lastRun.error && ( {job.lastRun && !job.lastRun.success && job.lastRun.error && (
<div className="flex items-start gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-600 dark:text-red-400"> <div className="flex items-start gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-600 dark:text-red-400">
@@ -433,12 +459,12 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
<span>{job.lastRun.error}</span> <span>{job.lastRun.error}</span>
</div> </div>
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-1 pt-2 border-t"> <div className="flex justify-end gap-1 pt-2 border-t">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleTrigger} onClick={handleTrigger}
disabled={triggering} disabled={triggering}
> >
@@ -469,9 +495,9 @@ export function Cron() {
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [editingJob, setEditingJob] = useState<CronJob | undefined>(); const [editingJob, setEditingJob] = useState<CronJob | undefined>();
const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayRunning = gatewayStatus.state === 'running';
// Fetch jobs and channels on mount // Fetch jobs and channels on mount
useEffect(() => { useEffect(() => {
if (isGatewayRunning) { if (isGatewayRunning) {
@@ -479,12 +505,12 @@ export function Cron() {
fetchChannels(); fetchChannels();
} }
}, [fetchJobs, fetchChannels, isGatewayRunning]); }, [fetchJobs, fetchChannels, isGatewayRunning]);
// Statistics // Statistics
const activeJobs = jobs.filter((j) => j.enabled); const activeJobs = jobs.filter((j) => j.enabled);
const pausedJobs = jobs.filter((j) => !j.enabled); const pausedJobs = jobs.filter((j) => !j.enabled);
const failedJobs = jobs.filter((j) => j.lastRun && !j.lastRun.success); const failedJobs = jobs.filter((j) => j.lastRun && !j.lastRun.success);
const handleSave = useCallback(async (input: CronJobCreateInput) => { const handleSave = useCallback(async (input: CronJobCreateInput) => {
if (editingJob) { if (editingJob) {
await updateJob(editingJob.id, input); await updateJob(editingJob.id, input);
@@ -492,7 +518,7 @@ export function Cron() {
await createJob(input); await createJob(input);
} }
}, [editingJob, createJob, updateJob]); }, [editingJob, createJob, updateJob]);
const handleToggle = useCallback(async (id: string, enabled: boolean) => { const handleToggle = useCallback(async (id: string, enabled: boolean) => {
try { try {
await toggleJob(id, enabled); await toggleJob(id, enabled);
@@ -501,7 +527,7 @@ export function Cron() {
toast.error('Failed to update task'); toast.error('Failed to update task');
} }
}, [toggleJob]); }, [toggleJob]);
const handleDelete = useCallback(async (id: string) => { const handleDelete = useCallback(async (id: string) => {
try { try {
await deleteJob(id); await deleteJob(id);
@@ -510,7 +536,7 @@ export function Cron() {
toast.error('Failed to delete task'); toast.error('Failed to delete task');
} }
}, [deleteJob]); }, [deleteJob]);
if (loading) { if (loading) {
return ( return (
<div className="flex h-96 items-center justify-center"> <div className="flex h-96 items-center justify-center">
@@ -518,7 +544,7 @@ export function Cron() {
</div> </div>
); );
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -534,7 +560,7 @@ export function Cron() {
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setEditingJob(undefined); setEditingJob(undefined);
setShowDialog(true); setShowDialog(true);
@@ -546,7 +572,7 @@ export function Cron() {
</Button> </Button>
</div> </div>
</div> </div>
{/* Gateway Warning */} {/* Gateway Warning */}
{!isGatewayRunning && ( {!isGatewayRunning && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10"> <Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
@@ -558,7 +584,7 @@ export function Cron() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Statistics */} {/* Statistics */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<Card> <Card>
@@ -614,7 +640,7 @@ export function Cron() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Error Display */} {/* Error Display */}
{error && ( {error && (
<Card className="border-destructive"> <Card className="border-destructive">
@@ -624,7 +650,7 @@ export function Cron() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Jobs List */} {/* Jobs List */}
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<Card> <Card>
@@ -632,10 +658,10 @@ export function Cron() {
<Clock className="h-12 w-12 text-muted-foreground mb-4" /> <Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No scheduled tasks</h3> <h3 className="text-lg font-medium mb-2">No scheduled tasks</h3>
<p className="text-muted-foreground text-center mb-4 max-w-md"> <p className="text-muted-foreground text-center mb-4 max-w-md">
Create scheduled tasks to automate AI workflows. Create scheduled tasks to automate AI workflows.
Tasks can send messages, run queries, or perform actions at specified times. Tasks can send messages, run queries, or perform actions at specified times.
</p> </p>
<Button <Button
onClick={() => { onClick={() => {
setEditingJob(undefined); setEditingJob(undefined);
setShowDialog(true); setShowDialog(true);
@@ -664,7 +690,7 @@ export function Cron() {
))} ))}
</div> </div>
)} )}
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
{showDialog && ( {showDialog && (
<TaskDialog <TaskDialog