From b1db66ac8c949b89926fa3a7ef0d65227e482513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 01:44:44 +0800 Subject: [PATCH] feat: add scheduled task edit UI, CronUpdateTool, wizard components and tests --- .claude/scheduled_tasks.json | 19 + PLAN.md | 332 ++++++++++++++++++ src/bootstrap/state.ts | 16 + .../scheduled-tasks/ScheduledTaskWizard.tsx | 49 +++ .../scheduled-tasks/steps/FolderStep.tsx | 124 +++++++ .../scheduled-tasks/steps/NameStep.tsx | 51 +++ .../scheduled-tasks/steps/PermissionStep.tsx | 55 +++ .../scheduled-tasks/steps/ScheduleStep.tsx | 101 ++++++ .../scheduled-tasks/steps/TaskConfirmStep.tsx | 69 ++++ .../steps/TaskDescriptionStep.tsx | 50 +++ .../scheduled-tasks/steps/TaskModelStep.tsx | 23 ++ .../scheduled-tasks/steps/TaskPromptStep.tsx | 50 +++ src/components/scheduled-tasks/types.ts | 12 + src/tools.ts | 1 + src/tools/ScheduleCronTool/CronListTool.ts | 18 +- src/tools/ScheduleCronTool/CronUpdateTool.ts | 180 ++++++++++ src/tools/ScheduleCronTool/UI.tsx | 24 +- src/tools/ScheduleCronTool/prompt.ts | 8 + src/utils/__tests__/cronFrequency.test.ts | 153 ++++++++ src/utils/__tests__/cronTasks.test.ts | 204 +++++++++++ src/utils/cronFrequency.ts | 119 +++++++ src/utils/cronTasks.ts | 103 +++++- 22 files changed, 1752 insertions(+), 9 deletions(-) create mode 100644 .claude/scheduled_tasks.json create mode 100644 PLAN.md create mode 100644 src/components/scheduled-tasks/ScheduledTaskWizard.tsx create mode 100644 src/components/scheduled-tasks/steps/FolderStep.tsx create mode 100644 src/components/scheduled-tasks/steps/NameStep.tsx create mode 100644 src/components/scheduled-tasks/steps/PermissionStep.tsx create mode 100644 src/components/scheduled-tasks/steps/ScheduleStep.tsx create mode 100644 src/components/scheduled-tasks/steps/TaskConfirmStep.tsx create mode 100644 src/components/scheduled-tasks/steps/TaskDescriptionStep.tsx create mode 100644 src/components/scheduled-tasks/steps/TaskModelStep.tsx create mode 100644 src/components/scheduled-tasks/steps/TaskPromptStep.tsx create mode 100644 src/components/scheduled-tasks/types.ts create mode 100644 src/tools/ScheduleCronTool/CronUpdateTool.ts create mode 100644 src/utils/__tests__/cronFrequency.test.ts create mode 100644 src/utils/__tests__/cronTasks.test.ts create mode 100644 src/utils/cronFrequency.ts diff --git a/.claude/scheduled_tasks.json b/.claude/scheduled_tasks.json new file mode 100644 index 00000000..cefe97df --- /dev/null +++ b/.claude/scheduled_tasks.json @@ -0,0 +1,19 @@ +{ + "tasks": [ + { + "id": "b4851079", + "cron": "0 9 * * *", + "prompt": "test prompt", + "createdAt": 1775497208158, + "recurring": true, + "name": "test-name", + "description": "test description", + "folder": "/test/folder", + "model": "claude-opus-4-6", + "permissionMode": "ask", + "worktree": false, + "frequency": "daily", + "scheduledTime": "09:00" + } + ] +} diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..7b4425c4 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,332 @@ +# Scheduled Tasks Enhancement Plan + +## Overview + +改进定时任务功能:支持编辑、新增工作目录选择、复用 sessions 组件。 + +参考官方 Claude Code 桌面端 APP 的 "New scheduled task" 对话框实现。 + +--- + +## Phase 1: Data Model Extension + +### 1.1 扩展 CronTask 类型 (`src/utils/cronTasks.ts`) + +在现有 `CronTask` 类型中新增以下字段: + +```typescript +type CronTask = { + // ... existing fields ... + + /** Human-readable task name (e.g. "daily-code-review") */ + name?: string + /** Task description (e.g. "Review yesterday's commits") */ + description?: string + /** Working directory for the task execution */ + folder?: string + /** Model to use (e.g. "claude-opus-4-6", "claude-sonnet-4-6") */ + model?: string + /** Permission mode: "ask" | "auto-accept" | "plan" | "bypass" */ + permissionMode?: string + /** Whether to use git worktree for execution */ + worktree?: boolean + /** Schedule frequency for UI display: "manual" | "hourly" | "daily" | "weekdays" | "weekly" */ + frequency?: string + /** Time string for scheduled execution (e.g. "09:00") - UI helper for daily/weekdays/weekly */ + scheduledTime?: string +} +``` + +**向后兼容**:所有新字段均为 optional,旧数据无需迁移。 + +### 1.2 更新存储 read/write (`src/utils/cronTasks.ts`) + +- `readCronTasks()`: 在读取时保留新字段(name, description, folder, model, permissionMode, worktree, frequency, scheduledTime) +- `writeCronTasks()`: 在写入时包含新字段(仍然 strip `durable` 和 `agentId`) +- `addCronTask()`: 扩展函数签名接收新字段 + +### 1.3 新增 `updateCronTask()` 函数 (`src/utils/cronTasks.ts`) + +```typescript +export async function updateCronTask( + id: string, + updates: Partial>, + dir?: string, +): Promise +``` + +- 查找并更新内存中(session store)或磁盘上的任务 +- 如果 cron 表达式变化,重新验证 +- 返回是否找到并更新成功 + +--- + +## Phase 2: CronUpdateTool (`src/tools/ScheduleCronTool/CronUpdateTool.ts`) + +### 2.1 创建新工具文件 + +参照 `CronCreateTool.ts` 和 `CronDeleteTool.ts` 的模式: + +```typescript +const inputSchema = z.strictObject({ + id: z.string().describe('Job ID returned by CronCreate.'), + cron: z.string().optional().describe('New cron expression'), + prompt: z.string().optional().describe('New prompt'), + name: z.string().optional().describe('New name'), + description: z.string().optional().describe('New description'), + folder: z.string().optional().describe('New working directory'), + model: z.string().optional().describe('New model'), + permissionMode: z.string().optional().describe('New permission mode'), + worktree: z.boolean().optional().describe('New worktree setting'), + recurring: z.boolean().optional().describe('New recurring setting'), + frequency: z.string().optional().describe('New frequency'), + scheduledTime: z.string().optional().describe('New time'), +}) +``` + +### 2.2 更新 prompt.ts + +- 新增 `CRON_UPDATE_TOOL_NAME = 'CronUpdate'` +- 添加 description 和 prompt 构建函数 + +### 2.3 更新 UI.tsx + +- 新增 `renderUpdateToolUseMessage()` 和 `renderUpdateResultMessage()` + +--- + +## Phase 3: UI Components (复用 sessions 组件) + +**核心原则**:所有可复用的组件直接 import 使用,不复制代码。sessions 那边改了,这边自动生效。 + +### 3.1 频率到 Cron 表达式映射工具 (`src/utils/cronFrequency.ts`) + +新建工具函数,在 UI 友好的频率设置和 cron 表达式之间互转: + +```typescript +// Frequency → Cron +function frequencyToCron(frequency: string, time?: string): string +// "daily" + "09:00" → "0 9 * * *" +// "hourly" → "0 * * * *" +// "weekdays" + "09:00" → "0 9 * * 1-5" +// "weekly" + "09:00" → "0 9 * * 1" + +// Cron → Frequency (best effort) +function cronToFrequency(cron: string): { frequency: string; time?: string } +``` + +### 3.2 定时任务向导 (`src/components/scheduled-tasks/ScheduledTaskWizard.tsx`) + +使用现有 **Wizard 框架** (`src/components/wizard/`),创建多步骤向导: + +```typescript +type ScheduledTaskWizardData = { + name: string + description: string + prompt: string + model?: string + permissionMode?: string + folder?: string + worktree?: boolean + frequency: string // "manual" | "hourly" | "daily" | "weekdays" | "weekly" + scheduledTime?: string // "09:00" + cron?: string // 最终生成的 cron 表达式 +} + +// 支持 create 和 edit 两种模式 +type Props = { + mode: 'create' | 'edit' + initialData?: Partial // edit 模式下预填充 + taskId?: string // edit 模式下的任务 ID + onComplete: (data: ScheduledTaskWizardData) => void + onCancel: () => void +} +``` + +**向导步骤**(每步复用 sessions 组件): + +| Step | 组件 | 复用来源 | +|------|------|---------| +| 1. NameStep | `TextInput` | `src/components/TextInput.tsx` | +| 2. DescriptionStep | `TextInput` + external editor | `src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx` 的模式 | +| 3. PromptStep | `TextInput` + external editor | `src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx` 的模式 | +| 4. ModelStep | `ModelSelector` | **直接复用** `src/components/agents/ModelSelector.tsx` | +| 5. PermissionStep | `Select` | **直接复用** `src/components/CustomSelect/select.tsx` | +| 6. FolderStep | `Select` / `FuzzyPicker` | **复用** `src/components/CustomSelect/select.tsx` + `sessionStorage.ts` 的项目发现 | +| 7. ScheduleStep | `Select` + `TextInput` | **直接复用** `src/components/CustomSelect/select.tsx` | +| 8. ConfirmStep | Summary display | 使用 `Dialog` + `Text` 显示摘要 | + +### 3.3 各步骤详细设计 + +#### Step 1: NameStep + +```tsx +// 直接使用 TextInput,参照 DescriptionStep 模式 +function NameStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = useWizard() + // TextInput with validation: name is required +} +``` + +#### Step 4: ModelStep(复用 ModelSelector) + +```tsx +import { ModelSelector } from '../../agents/ModelSelector.js' + +function ModelStep(): ReactNode { + const { goNext, updateWizardData, wizardData } = useWizard() + return ( + + { + updateWizardData({ model }) + goNext() + }} + onCancel={goBack} + /> + + ) +} +``` + +#### Step 5: PermissionStep(复用 Select) + +```tsx +import { Select } from '../../CustomSelect/select.js' + +const permissionOptions = [ + { label: 'Ask permissions', value: 'ask', description: 'Always ask before making changes' }, + { label: 'Auto accept edits', value: 'auto-accept', description: 'Automatically accept all file edits' }, + { label: 'Plan mode', value: 'plan', description: 'Create a plan before making changes' }, + { label: 'Bypass permissions', value: 'bypass', description: 'Accepts all permissions', disabled: false }, +] +``` + +#### Step 6: FolderStep(复用 sessionStorage 项目发现) + +```tsx +import { loadAllProjectsMessageLogs } from '../../utils/sessionStorage.js' +// 或者直接读取 GlobalConfig.projects 获取最近项目列表 + +function FolderStep(): ReactNode { + // 1. 从 GlobalConfig.projects 获取已知项目路径 + // 2. 使用 Select 展示 "Recent" 项目列表 + // 3. 最后一项 "Choose a different folder" 允许手动输入路径 +} +``` + +#### Step 7: ScheduleStep(频率 + 时间) + +```tsx +const frequencyOptions = [ + { label: 'Manual', value: 'manual' }, + { label: 'Hourly', value: 'hourly' }, + { label: 'Daily', value: 'daily' }, + { label: 'Weekdays', value: 'weekdays' }, + { label: 'Weekly', value: 'weekly' }, +] + +function ScheduleStep(): ReactNode { + // 1. 选择频率 (Select 组件) + // 2. 如果是 daily/weekdays/weekly,显示时间输入 (TextInput,格式 HH:MM) + // 3. 使用 frequencyToCron() 转换为 cron 表达式 +} +``` + +#### Step 8: ConfirmStep + +```tsx +function ConfirmStep(): ReactNode { + // 显示所有配置的摘要 + // Enter 确认创建/更新 + // Esc 返回上一步 +} +``` + +--- + +## Phase 4: Integration + +### 4.1 新增 /schedule-local Skill (`src/skills/bundled/scheduleLocal.ts`) + +或修改现有 `/schedule` skill,添加本地定时任务的向导流程: + +```typescript +// 用户输入 /schedule 时: +// 1. 如果没参数 → 显示 ScheduledTaskWizard (create 模式) +// 2. 如果参数是 task ID → 显示 ScheduledTaskWizard (edit 模式,预填充) +// 3. 如果参数是 "list" → 调用 CronList +``` + +### 4.2 注册 CronUpdateTool + +在工具注册表中添加 CronUpdateTool: + +- 更新 `src/tools/ScheduleCronTool/` 导出 +- 确保工具在 `isKairosCronEnabled()` 条件下启用 + +### 4.3 更新 CronListTool 输出 + +在列表输出中包含新字段(name, description, folder, model 等),方便用户识别任务。 + +--- + +## Phase 5: Testing + +### 5.1 单元测试 + +- `cronTasks.ts`: 测试 updateCronTask() 的各种场景(内存任务、磁盘任务、不存在的 ID) +- `cronFrequency.ts`: 测试频率到 cron 的双向转换 +- `CronUpdateTool.ts`: 测试验证逻辑(无效 ID、权限检查) + +### 5.2 集成测试 + +- 验证 wizard 创建的任务能被 scheduler 正确执行 +- 验证编辑后的任务能正确更新 nextFireAt + +--- + +## File Changes Summary + +| File | Change Type | Description | +|------|------------|-------------| +| `src/utils/cronTasks.ts` | **Modified** | 扩展 CronTask 类型,新增 updateCronTask(),更新 read/write/add | +| `src/utils/cronFrequency.ts` | **New** | 频率 ↔ Cron 表达式转换工具 | +| `src/tools/ScheduleCronTool/CronUpdateTool.ts` | **New** | 编辑定时任务工具 | +| `src/tools/ScheduleCronTool/prompt.ts` | **Modified** | 新增 CronUpdate 的 name/description/prompt | +| `src/tools/ScheduleCronTool/UI.tsx` | **Modified** | 新增 update 的 render 函数 | +| `src/components/scheduled-tasks/ScheduledTaskWizard.tsx` | **New** | 定时任务向导(复用 sessions 组件) | +| `src/components/scheduled-tasks/steps/NameStep.tsx` | **New** | 名称输入步骤 | +| `src/components/scheduled-tasks/steps/DescriptionStep.tsx` | **New** | 描述输入步骤 | +| `src/components/scheduled-tasks/steps/PromptStep.tsx` | **New** | Prompt 输入步骤 | +| `src/components/scheduled-tasks/steps/ModelStep.tsx` | **New** | 模型选择步骤(复用 ModelSelector) | +| `src/components/scheduled-tasks/steps/PermissionStep.tsx` | **New** | 权限模式步骤(复用 Select) | +| `src/components/scheduled-tasks/steps/FolderStep.tsx` | **New** | 工作目录步骤(复用 Select + 项目发现) | +| `src/components/scheduled-tasks/steps/ScheduleStep.tsx` | **New** | 频率+时间步骤(复用 Select) | +| `src/components/scheduled-tasks/steps/ConfirmStep.tsx` | **New** | 确认步骤 | +| `src/skills/bundled/scheduleLocal.ts` | **New or Modified** | /schedule skill 集成向导 | +| `src/hooks/useScheduledTasks.ts` | **Modified** | 支持 folder/model/permissionMode/worktree 在任务执行时生效 | + +## Key Reuse Points (复用列表) + +确保以下组件是直接 import 复用,**不是复制代码**: + +1. **`WizardProvider`** - `src/components/wizard/WizardProvider.tsx` +2. **`WizardDialogLayout`** - `src/components/wizard/WizardDialogLayout.tsx` +3. **`useWizard`** - `src/components/wizard/useWizard.ts` +4. **`WizardNavigationFooter`** - `src/components/wizard/WizardNavigationFooter.tsx` +5. **`ModelSelector`** - `src/components/agents/ModelSelector.tsx` +6. **`Select`** - `src/components/CustomSelect/select.tsx` +7. **`TextInput`** - `src/components/TextInput.tsx` +8. **`Dialog`** - `src/components/design-system/Dialog.tsx` +9. **`FuzzyPicker`** - `src/components/design-system/FuzzyPicker.tsx`(如 FolderStep 需要搜索) +10. **`useKeybinding`** - `src/hooks/useKeybinding.ts` +11. **Session Project Discovery** - `src/utils/sessionStorage.ts` (loadAllProjectsMessageLogs) +12. **GlobalConfig.projects** - `src/utils/config.ts`(最近项目列表) + +## Implementation Order + +1. Phase 1 (Data Model) → 2. Phase 2 (CronUpdateTool) → 3. Phase 3 (UI) → 4. Phase 4 (Integration) → 5. Phase 5 (Testing) + +每个 Phase 完成后做一次代码审查。 diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index d7199e5c..d8bfc6d7 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -1289,6 +1289,22 @@ export type SessionCronTask = { * instead of the main REPL command queue. Session-only — never written to disk. */ agentId?: string + /** Human-readable task name. */ + name?: string + /** Task description. */ + description?: string + /** Working directory for the task execution. */ + folder?: string + /** Model to use. */ + model?: string + /** Permission mode. */ + permissionMode?: string + /** Whether to use git worktree for execution. */ + worktree?: boolean + /** Schedule frequency for UI display. */ + frequency?: string + /** Time string for scheduled execution. */ + scheduledTime?: string } export function getSessionCronTasks(): SessionCronTask[] { diff --git a/src/components/scheduled-tasks/ScheduledTaskWizard.tsx b/src/components/scheduled-tasks/ScheduledTaskWizard.tsx new file mode 100644 index 00000000..04d76360 --- /dev/null +++ b/src/components/scheduled-tasks/ScheduledTaskWizard.tsx @@ -0,0 +1,49 @@ +import React, { type ReactNode } from 'react' +import { WizardProvider } from '../wizard/index.js' +import type { ScheduledTaskWizardData } from './types.js' +import { NameStep } from './steps/NameStep.js' +import { TaskDescriptionStep } from './steps/TaskDescriptionStep.js' +import { TaskPromptStep } from './steps/TaskPromptStep.js' +import { TaskModelStep } from './steps/TaskModelStep.js' +import { PermissionStep } from './steps/PermissionStep.js' +import { FolderStep } from './steps/FolderStep.js' +import { ScheduleStep } from './steps/ScheduleStep.js' +import { TaskConfirmStep } from './steps/TaskConfirmStep.js' + +type Props = { + mode: 'create' | 'edit' + initialData?: Partial + onComplete: (data: ScheduledTaskWizardData) => void + onCancel: () => void +} + +export function ScheduledTaskWizard({ + mode, + initialData = {}, + onComplete, + onCancel, +}: Props): ReactNode { + const steps = [ + NameStep, + TaskDescriptionStep, + TaskPromptStep, + TaskModelStep, + PermissionStep, + FolderStep, + ScheduleStep, + TaskConfirmStep, + ] + + const title = mode === 'create' ? 'New scheduled task' : 'Edit scheduled task' + + return ( + + ) +} diff --git a/src/components/scheduled-tasks/steps/FolderStep.tsx b/src/components/scheduled-tasks/steps/FolderStep.tsx new file mode 100644 index 00000000..abc376b4 --- /dev/null +++ b/src/components/scheduled-tasks/steps/FolderStep.tsx @@ -0,0 +1,124 @@ +import React, { type ReactNode, useMemo, useState } from 'react' +import { Box, Text } from '../../../ink.js' +import { getProjectRoot } from '../../../bootstrap/state.js' +import { useKeybinding } from '../../../hooks/useKeybinding.js' +import TextInput from '../../TextInput.js' +import { Select } from '../../CustomSelect/select.js' +import { WizardDialogLayout } from '../../wizard/index.js' +import { useWizard } from '../../wizard/useWizard.js' +import type { ScheduledTaskWizardData } from '../types.js' + +/** Reject paths that escape the filesystem root or contain dangerous patterns. */ +function isSafePath(path: string): boolean { + const normalized = path.trim() + // Reject empty paths, absolute paths outside root, or traversal attempts. + if (!normalized) return false + if (normalized.startsWith('/')) return true // absolute paths are allowed + if (normalized.startsWith('~')) return true // home directory is allowed + // Disallow traversal patterns. + if (normalized.includes('..')) return false + return true +} + +export function FolderStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + const [customPath, setCustomPath] = useState(false) + const [pathValue, setPathValue] = useState(wizardData.folder ?? '') + const [pathError, setPathError] = useState(null) + + const currentProject = getProjectRoot() + + useKeybinding('confirm:no', () => { + if (customPath) { + setCustomPath(false) + } else { + goBack() + } + }, { context: 'Settings' }) + + const folderOptions = useMemo(() => { + const options: { label: string; value: string; description?: string }[] = [] + + // Current project is always first + options.push({ + label: currentProject.split('/').pop() ?? currentProject, + value: currentProject, + description: currentProject, + }) + + return options + }, [currentProject]) + + // Custom path input mode — uses TextInput instead of Select input type + if (customPath) { + return ( + + + + Enter the full path to the working directory: + + { + setPathValue(v) + setPathError(null) + }} + onSubmit={() => { + const trimmed = pathValue.trim() + if (!trimmed) { + setPathError('Path cannot be empty') + return + } + if (!isSafePath(trimmed)) { + setPathError('Invalid path') + return + } + setPathError(null) + updateWizardData({ folder: trimmed }) + goNext() + }} + placeholder="/path/to/project" + /> + {pathError && ( + + {pathError} + + )} + + + ) + } + + return ( + + + + + Select the folder where this task will run. + + + { + updateWizardData({ permissionMode: value }) + goNext() + }} + onCancel={goBack} + /> + + + ) +} diff --git a/src/components/scheduled-tasks/steps/ScheduleStep.tsx b/src/components/scheduled-tasks/steps/ScheduleStep.tsx new file mode 100644 index 00000000..6e340b31 --- /dev/null +++ b/src/components/scheduled-tasks/steps/ScheduleStep.tsx @@ -0,0 +1,101 @@ +import React, { type ReactNode, useState } from 'react' +import { Box, Text } from '../../../ink.js' +import { useKeybinding } from '../../../hooks/useKeybinding.js' +import { Select } from '../../CustomSelect/select.js' +import TextInput from '../../TextInput.js' +import { WizardDialogLayout } from '../../wizard/index.js' +import { useWizard } from '../../wizard/useWizard.js' +import { + FREQUENCY_OPTIONS, + frequencyToCron, + type Frequency, +} from '../../../utils/cronFrequency.js' +import type { ScheduledTaskWizardData } from '../types.js' + +export function ScheduleStep(): ReactNode { + const { goNext, goBack, updateWizardData, wizardData } = + useWizard() + + const [frequency, setFrequency] = useState( + (wizardData.frequency as Frequency) ?? 'daily', + ) + const [showTimePicker, setShowTimePicker] = useState(false) + const [time, setTime] = useState(wizardData.scheduledTime ?? '09:00') + + useKeybinding('confirm:no', goBack, { context: 'Settings' }) + + const needsTime = frequency === 'daily' || frequency === 'weekdays' || frequency === 'weekly' + + const handleFrequencySelect = (value: string) => { + const freq = value as Frequency + setFrequency(freq) + + if (freq === 'manual' || freq === 'hourly') { + // No time needed + const cron = frequencyToCron(freq) + updateWizardData({ + frequency: freq, + scheduledTime: undefined, + cron: cron || undefined, + }) + goNext() + } else { + // Show time picker for daily/weekdays/weekly + setShowTimePicker(true) + } + } + + const handleTimeSubmit = () => { + // Validate time format HH:MM + if (!/^\d{1,2}:\d{2}$/.test(time)) return + const cron = frequencyToCron(frequency, time) + updateWizardData({ + frequency, + scheduledTime: time, + cron: cron || undefined, + }) + goNext() + } + + if (showTimePicker && needsTime) { + return ( + + + + + Enter the time for this task (24-hour format, e.g. 09:00): + + + + + + Scheduled tasks use a randomized delay of several minutes for + server performance. + + + + + ) + } + + return ( + + + + How often should this task run? + +