feat(cron): allow users to associate cron jobs with specific agents (#835)
This commit is contained in:
committed by
GitHub
Unverified
parent
49518300dc
commit
87ab12849c
@@ -347,6 +347,14 @@ function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, un
|
|||||||
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
|
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('agentId' in patch) {
|
||||||
|
const agentId = typeof patch.agentId === 'string' && patch.agentId.trim()
|
||||||
|
? patch.agentId.trim()
|
||||||
|
: 'main';
|
||||||
|
patch.agentId = agentId;
|
||||||
|
// Keep sessionTarget as isolated when agentId changes
|
||||||
|
}
|
||||||
|
|
||||||
return patch;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +385,9 @@ function transformCronJob(job: GatewayCronJob) {
|
|||||||
? new Date(job.state.nextRunAtMs).toISOString()
|
? new Date(job.state.nextRunAtMs).toISOString()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Parse agentId from the job's agentId field
|
||||||
|
const agentId = (job as unknown as { agentId?: string }).agentId || 'main';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
@@ -389,6 +400,7 @@ function transformCronJob(job: GatewayCronJob) {
|
|||||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||||
lastRun,
|
lastRun,
|
||||||
nextRun,
|
nextRun,
|
||||||
|
agentId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +527,11 @@ export async function handleCronRoutes(
|
|||||||
schedule: string;
|
schedule: string;
|
||||||
delivery?: GatewayCronDelivery;
|
delivery?: GatewayCronDelivery;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
agentId?: string;
|
||||||
}>(req);
|
}>(req);
|
||||||
|
const agentId = typeof input.agentId === 'string' && input.agentId.trim()
|
||||||
|
? input.agentId.trim()
|
||||||
|
: 'main';
|
||||||
const delivery = normalizeCronDelivery(input.delivery);
|
const delivery = normalizeCronDelivery(input.delivery);
|
||||||
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
|
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
|
||||||
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
|
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
|
||||||
@@ -529,6 +545,7 @@ export async function handleCronRoutes(
|
|||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
wakeMode: 'next-heartbeat',
|
wakeMode: 'next-heartbeat',
|
||||||
sessionTarget: 'isolated',
|
sessionTarget: 'isolated',
|
||||||
|
agentId,
|
||||||
delivery,
|
delivery,
|
||||||
});
|
});
|
||||||
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"taskNamePlaceholder": "e.g., Morning briefing",
|
"taskNamePlaceholder": "e.g., Morning briefing",
|
||||||
"message": "Message / Prompt",
|
"message": "Message / Prompt",
|
||||||
"messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather",
|
"messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather",
|
||||||
|
"agent": "Agent",
|
||||||
"schedule": "Schedule",
|
"schedule": "Schedule",
|
||||||
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
|
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
|
||||||
"usePresets": "Use presets",
|
"usePresets": "Use presets",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"taskNamePlaceholder": "例:朝のブリーフィング",
|
"taskNamePlaceholder": "例:朝のブリーフィング",
|
||||||
"message": "メッセージ / プロンプト",
|
"message": "メッセージ / プロンプト",
|
||||||
"messagePlaceholder": "AIに何をさせますか? 例:今日のニュースと天気のまとめを作成",
|
"messagePlaceholder": "AIに何をさせますか? 例:今日のニュースと天気のまとめを作成",
|
||||||
|
"agent": "エージェント",
|
||||||
"schedule": "スケジュール",
|
"schedule": "スケジュール",
|
||||||
"cronPlaceholder": "Cron式(例:0 9 * * *)",
|
"cronPlaceholder": "Cron式(例:0 9 * * *)",
|
||||||
"usePresets": "プリセットを使用",
|
"usePresets": "プリセットを使用",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"taskNamePlaceholder": "напр., Утренняя сводка",
|
"taskNamePlaceholder": "напр., Утренняя сводка",
|
||||||
"message": "Сообщение / Промпт",
|
"message": "Сообщение / Промпт",
|
||||||
"messagePlaceholder": "Что должен сделать AI? напр., Дай мне сводку новостей и погоды на сегодня",
|
"messagePlaceholder": "Что должен сделать AI? напр., Дай мне сводку новостей и погоды на сегодня",
|
||||||
|
"agent": "Агент",
|
||||||
"schedule": "Расписание",
|
"schedule": "Расписание",
|
||||||
"cronPlaceholder": "Cron-выражение (напр., 0 9 * * *)",
|
"cronPlaceholder": "Cron-выражение (напр., 0 9 * * *)",
|
||||||
"usePresets": "Использовать пресеты",
|
"usePresets": "Использовать пресеты",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"taskNamePlaceholder": "例如:早间简报",
|
"taskNamePlaceholder": "例如:早间简报",
|
||||||
"message": "消息/提示词",
|
"message": "消息/提示词",
|
||||||
"messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要",
|
"messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要",
|
||||||
|
"agent": "智能体",
|
||||||
"schedule": "调度计划",
|
"schedule": "调度计划",
|
||||||
"cronPlaceholder": "Cron 表达式 (例如:0 9 * * *)",
|
"cronPlaceholder": "Cron 表达式 (例如:0 9 * * *)",
|
||||||
"usePresets": "使用预设",
|
"usePresets": "使用预设",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
History,
|
History,
|
||||||
Pause,
|
Pause,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -32,6 +33,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { useCronStore } from '@/stores/cron';
|
import { useCronStore } from '@/stores/cron';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useAgentsStore } from '@/stores/agents';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { formatRelativeTime, cn } from '@/lib/utils';
|
import { formatRelativeTime, cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -243,9 +246,12 @@ interface TaskDialogProps {
|
|||||||
function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) {
|
function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) {
|
||||||
const { t } = useTranslation('cron');
|
const { t } = useTranslation('cron');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
|
|
||||||
const [name, setName] = useState(job?.name || '');
|
const [name, setName] = useState(job?.name || '');
|
||||||
const [message, setMessage] = useState(job?.message || '');
|
const [message, setMessage] = useState(job?.message || '');
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState(job?.agentId || useChatStore.getState().currentAgentId);
|
||||||
|
const [agentIdChanged, setAgentIdChanged] = useState(false);
|
||||||
// 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
|
||||||
const initialSchedule = (() => {
|
const initialSchedule = (() => {
|
||||||
const s = job?.schedule;
|
const s = job?.schedule;
|
||||||
@@ -405,6 +411,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
|
|||||||
schedule: finalSchedule,
|
schedule: finalSchedule,
|
||||||
delivery: finalDelivery,
|
delivery: finalDelivery,
|
||||||
enabled,
|
enabled,
|
||||||
|
...(agentIdChanged ? { agentId: selectedAgentId } : {}),
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
toast.success(job ? t('toast.updated') : t('toast.created'));
|
toast.success(job ? t('toast.updated') : t('toast.created'));
|
||||||
@@ -453,6 +460,26 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Agent */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Label htmlFor="agent" className="text-[14px] text-foreground/80 font-bold">{t('dialog.agent')}</Label>
|
||||||
|
<SelectField
|
||||||
|
id="agent"
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAgentId(e.target.value);
|
||||||
|
setAgentIdChanged(true);
|
||||||
|
}}
|
||||||
|
className="h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-[#eeece3] dark:bg-muted text-[13px]"
|
||||||
|
>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectField>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Schedule */}
|
{/* Schedule */}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Label className="text-[14px] text-foreground/80 font-bold">{t('dialog.schedule')}</Label>
|
<Label className="text-[14px] text-foreground/80 font-bold">{t('dialog.schedule')}</Label>
|
||||||
@@ -685,6 +712,8 @@ interface CronJobCardProps {
|
|||||||
function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
||||||
const { t } = useTranslation('cron');
|
const { t } = useTranslation('cron');
|
||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
const agents = useAgentsStore((s) => s.agents);
|
||||||
|
const agentName = agents.find((a) => a.id === job.agentId)?.name ?? job.agentId;
|
||||||
|
|
||||||
const handleTrigger = async (e: React.MouseEvent) => {
|
const handleTrigger = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -787,6 +816,11 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
|
|||||||
{t('card.next')}: {new Date(job.nextRun).toLocaleString()}
|
{t('card.next')}: {new Date(job.nextRun).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
{agentName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last Run Error */}
|
{/* Last Run Error */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
|
import { useChatStore } from './chat';
|
||||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||||
|
|
||||||
interface CronState {
|
interface CronState {
|
||||||
@@ -34,10 +35,47 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
} else {
|
} else {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||||
set({ jobs: result, loading: false });
|
|
||||||
|
// If Gateway returned fewer jobs than we have, something might be wrong - preserve all known jobs
|
||||||
|
// and just update agentIds from localStorage for the ones Gateway returned.
|
||||||
|
// Priority: API agentId (if non-'main') > currentJobs > localStorage > 'main'
|
||||||
|
const resultIds = new Set(result.map(j => j.id));
|
||||||
|
const savedAgentIdMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
|
||||||
|
|
||||||
|
// Update localStorage agentId map with current data
|
||||||
|
const newAgentIdMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
// For jobs returned by Gateway, restore agentId
|
||||||
|
const jobsWithAgentId = result.map((job) => {
|
||||||
|
// Priority: API response (if non-'main') > currentJobs > localStorage > default 'main'
|
||||||
|
const existingJob = currentJobs.find((j) => j.id === job.id);
|
||||||
|
const savedAgentId = savedAgentIdMap[job.id];
|
||||||
|
let agentId = job.agentId;
|
||||||
|
if (!agentId || agentId === 'main') {
|
||||||
|
// API returned 'main' or nothing — use cached value
|
||||||
|
if (existingJob && existingJob.agentId !== 'main') {
|
||||||
|
agentId = existingJob.agentId;
|
||||||
|
} else if (savedAgentId && savedAgentId !== 'main') {
|
||||||
|
agentId = savedAgentId;
|
||||||
|
} else {
|
||||||
|
agentId = 'main';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (agentId !== 'main') {
|
||||||
|
newAgentIdMap[job.id] = agentId;
|
||||||
|
}
|
||||||
|
return { ...job, agentId };
|
||||||
|
});
|
||||||
|
|
||||||
|
// If Gateway returned fewer jobs, preserve extra jobs from current state
|
||||||
|
const extraJobs = currentJobs.filter(j => !resultIds.has(j.id));
|
||||||
|
const allJobs = [...jobsWithAgentId, ...extraJobs];
|
||||||
|
|
||||||
|
localStorage.setItem('cronAgentIdMap', JSON.stringify(newAgentIdMap));
|
||||||
|
set({ jobs: allJobs, loading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Preserve previous jobs on error so the user sees stale data instead of nothing.
|
// Preserve previous jobs on error so the user sees stale data instead of nothing.
|
||||||
set({ error: String(error), loading: false });
|
set({ error: String(error), loading: false });
|
||||||
@@ -46,12 +84,19 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
|
|
||||||
createJob: async (input) => {
|
createJob: async (input) => {
|
||||||
try {
|
try {
|
||||||
|
// Auto-capture currentAgentId if not provided
|
||||||
|
const agentId = input.agentId ?? useChatStore.getState().currentAgentId;
|
||||||
const job = await hostApiFetch<CronJob>('/api/cron/jobs', {
|
const job = await hostApiFetch<CronJob>('/api/cron/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, agentId }),
|
||||||
});
|
});
|
||||||
set((state) => ({ jobs: [...state.jobs, job] }));
|
const jobWithAgentId = { ...job, agentId };
|
||||||
return job;
|
// Persist agentId to localStorage (since Gateway doesn't return it)
|
||||||
|
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
|
||||||
|
savedMap[jobWithAgentId.id] = agentId;
|
||||||
|
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
|
||||||
|
set((state) => ({ jobs: [...state.jobs, jobWithAgentId] }));
|
||||||
|
return jobWithAgentId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create cron job:', error);
|
console.error('Failed to create cron job:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -60,13 +105,44 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
|
|
||||||
updateJob: async (id, input) => {
|
updateJob: async (id, input) => {
|
||||||
try {
|
try {
|
||||||
|
const currentJob = useCronStore.getState().jobs.find((j) => j.id === id);
|
||||||
|
const newAgentId = input.agentId;
|
||||||
|
|
||||||
|
// If agentId changed, recreate with new agentId first then delete old one (Gateway doesn't support updating sessionTarget)
|
||||||
|
if (newAgentId && currentJob && newAgentId !== currentJob.agentId) {
|
||||||
|
// Create new job with new agentId first (preserves schedule on failure)
|
||||||
|
const { agentId: _agentId, ...restInput } = input;
|
||||||
|
const newJob = await hostApiFetch<CronJob>('/api/cron/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...restInput, agentId: newAgentId }),
|
||||||
|
});
|
||||||
|
const jobWithAgentId = { ...currentJob, ...newJob, agentId: newAgentId };
|
||||||
|
// Update localStorage: add new id first, then remove old id
|
||||||
|
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
|
||||||
|
savedMap[jobWithAgentId.id] = newAgentId;
|
||||||
|
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
|
||||||
|
// Delete old job after new one is created successfully
|
||||||
|
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
delete savedMap[id];
|
||||||
|
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((j) => (j.id === id ? jobWithAgentId : j)),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal update for other fields - use currentJob as base, overlay updatedJob to preserve fields
|
||||||
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
|
// Merge: updatedJob fields override currentJob, but preserve currentJob fields not in updatedJob
|
||||||
|
const jobWithAgentId = { ...currentJob, ...updatedJob, agentId: currentJob?.agentId ?? updatedJob.agentId };
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
jobs: state.jobs.map((job) =>
|
jobs: state.jobs.map((job) =>
|
||||||
job.id === id ? updatedJob : job
|
job.id === id ? jobWithAgentId : job
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,6 +156,10 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
// Remove from localStorage
|
||||||
|
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
|
||||||
|
delete savedMap[id];
|
||||||
|
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
jobs: state.jobs.filter((job) => job.id !== id),
|
jobs: state.jobs.filter((job) => job.id !== id),
|
||||||
}));
|
}));
|
||||||
@@ -108,15 +188,20 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
|
|
||||||
triggerJob: async (id) => {
|
triggerJob: async (id) => {
|
||||||
try {
|
try {
|
||||||
const result = await hostApiFetch('/api/cron/trigger', {
|
await hostApiFetch('/api/cron/trigger', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
console.log('Cron trigger result:', result);
|
|
||||||
// Refresh jobs after trigger to update lastRun/nextRun state
|
// Refresh jobs after trigger to update lastRun/nextRun state
|
||||||
try {
|
try {
|
||||||
const jobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
const currentJobs = useCronStore.getState().jobs;
|
||||||
set({ jobs });
|
const resultJobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||||
|
// Preserve agentId from existing jobs
|
||||||
|
const jobsWithAgentId = resultJobs.map((job) => {
|
||||||
|
const existing = currentJobs.find((j) => j.id === job.id);
|
||||||
|
return existing ? { ...job, agentId: existing.agentId } : job;
|
||||||
|
});
|
||||||
|
set({ jobs: jobsWithAgentId });
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore refresh error
|
// Ignore refresh error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface CronJob {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastRun?: CronJobLastRun;
|
lastRun?: CronJobLastRun;
|
||||||
nextRun?: string;
|
nextRun?: string;
|
||||||
|
agentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,7 @@ export interface CronJobCreateInput {
|
|||||||
schedule: string;
|
schedule: string;
|
||||||
delivery?: CronJobDelivery;
|
delivery?: CronJobDelivery;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +82,7 @@ export interface CronJobUpdateInput {
|
|||||||
schedule?: string;
|
schedule?: string;
|
||||||
delivery?: CronJobDelivery;
|
delivery?: CronJobDelivery;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
276
tests/unit/cron-store.test.ts
Normal file
276
tests/unit/cron-store.test.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* Cron Store Tests
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { useCronStore } from '@/stores/cron';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
|
||||||
|
// Mock hostApiFetch
|
||||||
|
const mockHostApiFetch = vi.fn();
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => mockHostApiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
data: {} as Record<string, string>,
|
||||||
|
getItem: vi.fn((key: string) => localStorageMock.data[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => { localStorageMock.data[key] = value; }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete localStorageMock.data[key]; }),
|
||||||
|
clear: vi.fn(() => { localStorageMock.data = {}; }),
|
||||||
|
};
|
||||||
|
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||||
|
|
||||||
|
describe('Cron Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
localStorageMock.data = {};
|
||||||
|
// Reset stores to default state
|
||||||
|
useCronStore.setState({ jobs: [], loading: false, error: null });
|
||||||
|
useChatStore.setState({ currentAgentId: 'main', currentSessionKey: 'agent:main:session-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorageMock.data = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchJobs', () => {
|
||||||
|
it('preserves agentId from localStorage when Gateway does not return agentId', async () => {
|
||||||
|
// Pre-populate localStorage with job -> agentId mapping
|
||||||
|
localStorageMock.data['cronAgentIdMap'] = JSON.stringify({
|
||||||
|
'job-1': 'typ-2',
|
||||||
|
'job-2': 'agent-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gateway returns jobs WITHOUT agentId field
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce([
|
||||||
|
{ id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
{ id: 'job-2', name: 'Job 2', agentId: 'main', schedule: '0 10 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useCronStore.getState().fetchJobs();
|
||||||
|
|
||||||
|
const jobs = useCronStore.getState().jobs;
|
||||||
|
expect(jobs.find(j => j.id === 'job-1')?.agentId).toBe('typ-2');
|
||||||
|
expect(jobs.find(j => j.id === 'job-2')?.agentId).toBe('agent-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves extra jobs not returned by Gateway', async () => {
|
||||||
|
// Pre-populate localStorage
|
||||||
|
localStorageMock.data['cronAgentIdMap'] = JSON.stringify({});
|
||||||
|
|
||||||
|
// Set existing job in store
|
||||||
|
useCronStore.setState({
|
||||||
|
jobs: [
|
||||||
|
{ id: 'job-extra', name: 'Extra Job', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gateway returns fewer jobs (missing job-extra)
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce([
|
||||||
|
{ id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useCronStore.getState().fetchJobs();
|
||||||
|
|
||||||
|
const jobs = useCronStore.getState().jobs;
|
||||||
|
expect(jobs.length).toBe(2);
|
||||||
|
expect(jobs.find(j => j.id === 'job-extra')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to main agent when localStorage has no mapping', async () => {
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce([
|
||||||
|
{ id: 'job-1', name: 'Job 1', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useCronStore.getState().fetchJobs();
|
||||||
|
|
||||||
|
const jobs = useCronStore.getState().jobs;
|
||||||
|
expect(jobs[0].agentId).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createJob', () => {
|
||||||
|
it('auto-captures currentAgentId when agentId is not provided', async () => {
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({
|
||||||
|
id: 'new-job',
|
||||||
|
name: 'New Job',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||||
|
enabled: true,
|
||||||
|
message: 'Hi',
|
||||||
|
delivery: { mode: 'none' },
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useChatStore.setState({ currentAgentId: 'typ-2' });
|
||||||
|
|
||||||
|
await useCronStore.getState().createJob({
|
||||||
|
name: 'New Job',
|
||||||
|
message: 'Hi',
|
||||||
|
schedule: '0 9 * * *',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify agentId was sent to API
|
||||||
|
const [, init] = mockHostApiFetch.mock.calls[0] as [string, Record<string, unknown>];
|
||||||
|
expect((init as { body: string }).body).toContain('"agentId":"typ-2"');
|
||||||
|
|
||||||
|
// Verify localStorage was updated
|
||||||
|
expect(localStorageMock.data['cronAgentIdMap']).toContain('typ-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided agentId when explicitly passed', async () => {
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({
|
||||||
|
id: 'new-job',
|
||||||
|
name: 'New Job',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||||
|
enabled: true,
|
||||||
|
message: 'Hi',
|
||||||
|
delivery: { mode: 'none' },
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await useCronStore.getState().createJob({
|
||||||
|
name: 'New Job',
|
||||||
|
message: 'Hi',
|
||||||
|
schedule: '0 9 * * *',
|
||||||
|
agentId: 'agent-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = mockHostApiFetch.mock.calls[0] as [string, Record<string, unknown>];
|
||||||
|
expect((init as { body: string }).body).toContain('"agentId":"agent-5"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists agentId to localStorage', async () => {
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({
|
||||||
|
id: 'job-xyz',
|
||||||
|
name: 'Job',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||||
|
enabled: true,
|
||||||
|
message: 'Hi',
|
||||||
|
delivery: { mode: 'none' },
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useChatStore.setState({ currentAgentId: 'custom-agent' });
|
||||||
|
|
||||||
|
await useCronStore.getState().createJob({
|
||||||
|
name: 'Job',
|
||||||
|
message: 'Hi',
|
||||||
|
schedule: '0 9 * * *',
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}');
|
||||||
|
expect(savedMap['job-xyz']).toBe('custom-agent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateJob', () => {
|
||||||
|
it('preserves agentId from currentJob when updating other fields', async () => {
|
||||||
|
useCronStore.setState({
|
||||||
|
jobs: [
|
||||||
|
{ id: 'job-1', name: 'Old Name', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT returns job with updated fields but missing agentId
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({
|
||||||
|
id: 'job-1',
|
||||||
|
name: 'New Name',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||||
|
enabled: true,
|
||||||
|
message: 'Updated',
|
||||||
|
delivery: { mode: 'none' },
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await useCronStore.getState().updateJob('job-1', {
|
||||||
|
name: 'New Name',
|
||||||
|
message: 'Updated',
|
||||||
|
schedule: '0 9 * * *',
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = useCronStore.getState().jobs.find(j => j.id === 'job-1');
|
||||||
|
expect(job?.agentId).toBe('typ-2');
|
||||||
|
expect(job?.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes and recreates job when agentId changes', async () => {
|
||||||
|
useCronStore.setState({
|
||||||
|
jobs: [
|
||||||
|
{ id: 'job-1', name: 'Job', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST call first (create new job before deleting old one)
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({
|
||||||
|
id: 'job-new',
|
||||||
|
name: 'Job',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||||
|
enabled: true,
|
||||||
|
message: 'Hi',
|
||||||
|
delivery: { mode: 'none' },
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
});
|
||||||
|
// DELETE call (delete old job after new one is created)
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await useCronStore.getState().updateJob('job-1', {
|
||||||
|
name: 'Job',
|
||||||
|
message: 'Hi',
|
||||||
|
schedule: '0 9 * * *',
|
||||||
|
agentId: 'new-agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have POST and DELETE calls
|
||||||
|
expect(mockHostApiFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Verify localStorage updated with new job id
|
||||||
|
const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}');
|
||||||
|
expect(savedMap['job-1']).toBeUndefined();
|
||||||
|
expect(savedMap['job-new']).toBe('new-agent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteJob', () => {
|
||||||
|
it('removes job from localStorage on delete', async () => {
|
||||||
|
localStorageMock.data['cronAgentIdMap'] = JSON.stringify({
|
||||||
|
'job-1': 'typ-2',
|
||||||
|
'job-2': 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await useCronStore.getState().deleteJob('job-1');
|
||||||
|
|
||||||
|
const savedMap = JSON.parse(localStorageMock.data['cronAgentIdMap'] || '{}');
|
||||||
|
expect(savedMap['job-1']).toBeUndefined();
|
||||||
|
expect(savedMap['job-2']).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('triggerJob', () => {
|
||||||
|
it('preserves agentId from currentJobs after refresh', async () => {
|
||||||
|
useCronStore.setState({
|
||||||
|
jobs: [
|
||||||
|
{ id: 'job-trigger', name: 'Triggered', agentId: 'typ-2', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce({}); // trigger call
|
||||||
|
// fetchJobs after trigger returns same job but without agentId
|
||||||
|
mockHostApiFetch.mockResolvedValueOnce([
|
||||||
|
{ id: 'job-trigger', name: 'Triggered', agentId: 'main', schedule: '0 9 * * *', enabled: true, message: 'Hi', delivery: { mode: 'none' }, createdAt: '', updatedAt: '', lastRun: { time: new Date().toISOString(), success: true } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await useCronStore.getState().triggerJob('job-trigger');
|
||||||
|
|
||||||
|
const job = useCronStore.getState().jobs.find(j => j.id === 'job-trigger');
|
||||||
|
expect(job?.agentId).toBe('typ-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user