mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
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:
parent
4281151ae8
commit
f8054601a8
9 changed files with 168 additions and 13 deletions
56
apps/desktop/src/app/cron/cron-job-model.test.ts
Normal file
56
apps/desktop/src/app/cron/cron-job-model.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
62
apps/desktop/src/app/cron/cron-job-model.ts
Normal file
62
apps/desktop/src/app/cron/cron-job-model.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 を作成'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '建立排程工作'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: '创建定时任务'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue