Merge pull request #62600 from HexLab98/fix/desktop-cron-no-agent-editor

fix(desktop): allow editing script-only (no_agent) cron jobs without a prompt
This commit is contained in:
HexLab 2026-07-12 13:32:29 +07:00 committed by GitHub
parent 4281151ae8
commit f8054601a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 168 additions and 13 deletions

View file

@ -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')
})
})

View file

@ -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<CronJob, 'no_agent' | 'script'>): 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
}

View file

@ -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({
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
{scriptOnlyJob && initial && (
<FieldHint>
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
</FieldHint>
)}
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input
autoFocus
@ -800,7 +816,12 @@ function CronEditorDialog({
/>
</Field>
<Field htmlFor="cron-prompt" label={c.promptLabel}>
<Field
htmlFor="cron-prompt"
label={c.promptLabel}
optional={scriptOnlyJob}
optionalLabel={c.optional}
>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"

View file

@ -1470,7 +1470,10 @@ export const en: Translations = {
customPlaceholder: '0 9 * * * or weekdays at 9am',
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
optional: 'Optional',
promptRequired: 'Prompt is required.',
promptScheduleRequired: 'Prompt and schedule are required.',
scheduleRequired: 'Schedule is required.',
scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:',
saveChanges: 'Save changes',
createAction: 'Create cron'
},

View file

@ -1415,7 +1415,10 @@ export const ja = defineLocale({
customPlaceholder: '0 9 * * * または weekdays at 9am',
customHint: 'Cron 式、または「every hour」「weekdays at 9am」のようなフレーズ。',
optional: '省略可能',
promptRequired: 'プロンプトは必須です。',
promptScheduleRequired: 'プロンプトとスケジュールは必須です。',
scheduleRequired: 'スケジュールは必須です。',
scriptOnlyEditHint: 'スクリプトのみのジョブAI プロンプトなし)。ジョブ ID:',
saveChanges: '変更を保存',
createAction: 'Cron を作成'
},

View file

@ -1204,7 +1204,10 @@ export interface Translations {
customPlaceholder: string
customHint: string
optional: string
promptRequired: string
promptScheduleRequired: string
scheduleRequired: string
scriptOnlyEditHint: string
saveChanges: string
createAction: string
}

View file

@ -1368,7 +1368,10 @@ export const zhHant = defineLocale({
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
customHint: 'Cron 表達式,或類似「每小時」「工作日上午 9 點」的短語。',
optional: '選填',
promptRequired: '提示詞為必填項目。',
promptScheduleRequired: '提示詞和排程為必填項目。',
scheduleRequired: '排程為必填項目。',
scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID',
saveChanges: '儲存變更',
createAction: '建立排程工作'
},

View file

@ -1647,7 +1647,10 @@ export const zh: Translations = {
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
optional: '可选',
promptRequired: '提示词为必填项。',
promptScheduleRequired: '提示词和排程为必填项。',
scheduleRequired: '排程为必填项。',
scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID',
saveChanges: '保存更改',
createAction: '创建定时任务'
},

View file

@ -556,6 +556,7 @@ export interface CronJob {
last_run_at?: null | string
name?: null | string
next_run_at?: null | string
no_agent?: boolean
prompt?: null | string
schedule?: CronJobSchedule
schedule_display?: null | string