diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts new file mode 100644 index 000000000000..14873e299877 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' + +describe('jobIsScriptOnly', () => { + it('is true when no_agent is set and a script is present', () => { + expect(jobIsScriptOnly({ no_agent: true, script: 'echo hi' })).toBe(true) + }) + + it('is false for agent-backed jobs', () => { + expect(jobIsScriptOnly({ no_agent: false, script: 'echo hi' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: '' })).toBe(false) + expect(jobIsScriptOnly({ no_agent: true, script: null })).toBe(false) + }) +}) + +describe('validateCronEditor', () => { + it('requires prompt and schedule for agent-backed jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: false })).toBe('prompt_and_schedule') + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * *', scriptOnlyJob: false })).toBe('prompt') + expect(validateCronEditor({ prompt: 'go', schedule: '', scriptOnlyJob: false })).toBe('schedule') + }) + + it('allows an empty prompt when editing a script-only job', () => { + expect(validateCronEditor({ prompt: '', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + expect(validateCronEditor({ prompt: 'optional note', schedule: '0 9 * * 1', scriptOnlyJob: true })).toBe(null) + }) + + it('still requires schedule for script-only jobs', () => { + expect(validateCronEditor({ prompt: '', schedule: '', scriptOnlyJob: true })).toBe('schedule') + }) +}) + +describe('cronEditorUpdates', () => { + it('omits prompt when saving a script-only job with an empty prompt', () => { + expect( + cronEditorUpdates( + { deliver: 'local', name: 'Weekly', prompt: '', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ) + ).toEqual({ + deliver: 'local', + name: 'Weekly', + schedule: '0 9 * * 1' + }) + }) + + it('includes prompt when the user typed one on a script-only job', () => { + expect( + cronEditorUpdates( + { deliver: 'email', name: 'Weekly', prompt: 'note', schedule: '0 9 * * 1' }, + { scriptOnlyJob: true } + ).prompt + ).toBe('note') + }) +}) diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts new file mode 100644 index 000000000000..38d3be879f70 --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -0,0 +1,62 @@ +import type { CronJob, CronJobUpdates } from '@/types/hermes' + +const asText = (value: unknown): string => (typeof value === 'string' ? value : '') + +/** Script-only cron jobs run a shell script on schedule with no LLM prompt. */ +export function jobIsScriptOnly(job: Pick): boolean { + return Boolean(job.no_agent) && Boolean(asText(job.script).trim()) +} + +export type CronEditorValidationError = 'prompt' | 'prompt_and_schedule' | 'schedule' + +export interface CronEditorValidationInput { + prompt: string + schedule: string + scriptOnlyJob: boolean +} + +export function validateCronEditor(input: CronEditorValidationInput): CronEditorValidationError | null { + const trimmedPrompt = input.prompt.trim() + const trimmedSchedule = input.schedule.trim() + + if (!trimmedSchedule && !trimmedPrompt && !input.scriptOnlyJob) { + return 'prompt_and_schedule' + } + + if (!trimmedSchedule) { + return 'schedule' + } + + if (!input.scriptOnlyJob && !trimmedPrompt) { + return 'prompt' + } + + return null +} + +export interface CronEditorSaveValues { + deliver: string + name: string + prompt: string + schedule: string +} + +/** Build the API update payload, preserving an empty prompt on script-only jobs. */ +export function cronEditorUpdates( + values: CronEditorSaveValues, + options: { scriptOnlyJob: boolean } +): CronJobUpdates { + const updates: CronJobUpdates = { + deliver: values.deliver, + name: values.name, + schedule: values.schedule.trim() + } + + const trimmedPrompt = values.prompt.trim() + + if (!options.scriptOnlyJob || trimmedPrompt) { + updates.prompt = trimmedPrompt + } + + return updates +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index a3d229ac5af5..2e9c65d5f5a1 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -55,6 +55,7 @@ import { import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { jobState, jobTitle, STATE_DOT } from './job-state' +import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model' const DEFAULT_DELIVER = 'local' @@ -396,12 +397,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt updateCronJobs(rows => [...rows, created]) notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { - const updated = await updateCronJob(editor.job.id, { - prompt: values.prompt, - schedule: values.schedule, - name: values.name, - deliver: values.deliver - }) + const scriptOnlyJob = jobIsScriptOnly(editor.job) + const updated = await updateCronJob( + editor.job.id, + cronEditorUpdates(values, { scriptOnlyJob }) + ) updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row))) notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) }) @@ -712,6 +712,7 @@ function CronEditorDialog({ const open = editor.mode !== 'closed' const isEdit = editor.mode === 'edit' const initial = isEdit ? editor.job : null + const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false const [name, setName] = useState('') const [prompt, setPrompt] = useState('') @@ -755,11 +756,20 @@ function CronEditorDialog({ async function handleSubmit(event: React.FormEvent) { event.preventDefault() - const trimmedPrompt = prompt.trim() - const trimmedSchedule = schedule.trim() + const validationError = validateCronEditor({ + prompt, + schedule, + scriptOnlyJob + }) - if (!trimmedPrompt || !trimmedSchedule) { - setError(c.promptScheduleRequired) + if (validationError) { + setError( + validationError === 'schedule' + ? c.scheduleRequired + : validationError === 'prompt' + ? c.promptRequired + : c.promptScheduleRequired + ) return } @@ -771,8 +781,8 @@ function CronEditorDialog({ await onSave({ deliver, name: name.trim(), - prompt: trimmedPrompt, - schedule: trimmedSchedule + prompt: prompt.trim(), + schedule: schedule.trim() }) } catch (err) { setError(err instanceof Error ? err.message : c.failedSave) @@ -790,6 +800,12 @@ function CronEditorDialog({
+ {scriptOnlyJob && initial && ( + + {c.scriptOnlyEditHint} {initial.id} + + )} + - +