mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'worktree-feature-scheduled-tasks' into docs/ui-clone-requirements
Resolve PLAN.md conflict by keeping both plans: Provider management system and Scheduled Tasks enhancement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
da2be1f81b
19
.claude/scheduled_tasks.json
Normal file
19
.claude/scheduled_tasks.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
336
PLAN.md
336
PLAN.md
@ -240,3 +240,339 @@ Body: { baseUrl, apiKey, modelId }
|
||||
- 多 API 格式支持(OpenAI 兼容等,V2 考虑)
|
||||
- Provider 导入/导出
|
||||
- 自动故障转移(failover)
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# 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<Omit<CronTask, 'id' | 'createdAt'>>,
|
||||
dir?: string,
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
- 查找并更新内存中(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<ScheduledTaskWizardData> // 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<ScheduledTaskWizardData>()
|
||||
// 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<ScheduledTaskWizardData>()
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Select Model">
|
||||
<ModelSelector
|
||||
initialModel={wizardData.model}
|
||||
onComplete={(model) => {
|
||||
updateWizardData({ model })
|
||||
goNext()
|
||||
}}
|
||||
onCancel={goBack}
|
||||
/>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 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 完成后做一次代码审查。
|
||||
|
||||
@ -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[] {
|
||||
|
||||
49
src/components/scheduled-tasks/ScheduledTaskWizard.tsx
Normal file
49
src/components/scheduled-tasks/ScheduledTaskWizard.tsx
Normal file
@ -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<ScheduledTaskWizardData>
|
||||
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 (
|
||||
<WizardProvider
|
||||
steps={steps}
|
||||
initialData={initialData as ScheduledTaskWizardData}
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
title={title}
|
||||
showStepCounter={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
124
src/components/scheduled-tasks/steps/FolderStep.tsx
Normal file
124
src/components/scheduled-tasks/steps/FolderStep.tsx
Normal file
@ -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<ScheduledTaskWizardData>()
|
||||
const [customPath, setCustomPath] = useState(false)
|
||||
const [pathValue, setPathValue] = useState(wizardData.folder ?? '')
|
||||
const [pathError, setPathError] = useState<string | null>(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 (
|
||||
<WizardDialogLayout subtitle="Working directory">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>Enter the full path to the working directory:</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={pathValue}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{pathError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Working directory">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Select the folder where this task will run.
|
||||
</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={[
|
||||
...folderOptions,
|
||||
{
|
||||
label: '+ Choose a different folder',
|
||||
value: '__custom__',
|
||||
description: 'Enter a custom path',
|
||||
},
|
||||
]}
|
||||
defaultValue={wizardData.folder ?? currentProject}
|
||||
onChange={(value) => {
|
||||
if (value === '__custom__') {
|
||||
setCustomPath(true)
|
||||
return
|
||||
}
|
||||
updateWizardData({ folder: value })
|
||||
goNext()
|
||||
}}
|
||||
onCancel={goBack}
|
||||
/>
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
51
src/components/scheduled-tasks/steps/NameStep.tsx
Normal file
51
src/components/scheduled-tasks/steps/NameStep.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { type ReactNode, useState } from 'react'
|
||||
import { Box, Text } from '../../../ink.js'
|
||||
import { useKeybinding } from '../../../hooks/useKeybinding.js'
|
||||
import TextInput from '../../TextInput.js'
|
||||
import { WizardDialogLayout } from '../../wizard/index.js'
|
||||
import { useWizard } from '../../wizard/useWizard.js'
|
||||
import type { ScheduledTaskWizardData } from '../types.js'
|
||||
|
||||
export function NameStep(): ReactNode {
|
||||
const { goNext, goBack, updateWizardData, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
const [value, setValue] = useState(wizardData.name ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useKeybinding('confirm:no', goBack, { context: 'Settings' })
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
setError('Name is required')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
updateWizardData({ name: trimmed })
|
||||
goNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Task name">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Give your scheduled task a short, descriptive name (e.g.
|
||||
"daily-code-review").
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="e.g. daily-code-review"
|
||||
/>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
55
src/components/scheduled-tasks/steps/PermissionStep.tsx
Normal file
55
src/components/scheduled-tasks/steps/PermissionStep.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { Box, Text } from '../../../ink.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'
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
export function PermissionStep(): ReactNode {
|
||||
const { goNext, goBack, updateWizardData, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Permission mode">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Choose the permission mode for this scheduled task.
|
||||
</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={PERMISSION_OPTIONS}
|
||||
defaultValue={wizardData.permissionMode ?? 'ask'}
|
||||
onChange={(value) => {
|
||||
updateWizardData({ permissionMode: value })
|
||||
goNext()
|
||||
}}
|
||||
onCancel={goBack}
|
||||
/>
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
101
src/components/scheduled-tasks/steps/ScheduleStep.tsx
Normal file
101
src/components/scheduled-tasks/steps/ScheduleStep.tsx
Normal file
@ -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<ScheduledTaskWizardData>()
|
||||
|
||||
const [frequency, setFrequency] = useState<Frequency>(
|
||||
(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 (
|
||||
<WizardDialogLayout subtitle="Schedule time">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Enter the time for this task (24-hour format, e.g. 09:00):
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={time}
|
||||
onChange={setTime}
|
||||
onSubmit={handleTimeSubmit}
|
||||
placeholder="09:00"
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Scheduled tasks use a randomized delay of several minutes for
|
||||
server performance.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Frequency">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>How often should this task run?</Text>
|
||||
</Box>
|
||||
<Select
|
||||
options={FREQUENCY_OPTIONS}
|
||||
defaultValue={frequency}
|
||||
onChange={handleFrequencySelect}
|
||||
onCancel={goBack}
|
||||
/>
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
69
src/components/scheduled-tasks/steps/TaskConfirmStep.tsx
Normal file
69
src/components/scheduled-tasks/steps/TaskConfirmStep.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { Box, Text } from '../../../ink.js'
|
||||
import { useKeybinding } from '../../../hooks/useKeybinding.js'
|
||||
import { cronToHuman } from '../../../utils/cron.js'
|
||||
import { WizardDialogLayout } from '../../wizard/index.js'
|
||||
import { useWizard } from '../../wizard/useWizard.js'
|
||||
import type { ScheduledTaskWizardData } from '../types.js'
|
||||
|
||||
export function TaskConfirmStep(): ReactNode {
|
||||
const { goNext, goBack, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
|
||||
useKeybinding('confirm:no', goBack, { context: 'Settings' })
|
||||
|
||||
const schedule = wizardData.cron
|
||||
? cronToHuman(wizardData.cron)
|
||||
: wizardData.frequency === 'manual'
|
||||
? 'Manual (on demand)'
|
||||
: 'Not set'
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Review & confirm">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold>Name: </Text>
|
||||
<Text>{wizardData.name ?? '—'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Description: </Text>
|
||||
<Text>{wizardData.description ?? '—'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Prompt: </Text>
|
||||
<Text>
|
||||
{wizardData.prompt
|
||||
? wizardData.prompt.length > 60
|
||||
? wizardData.prompt.slice(0, 57) + '...'
|
||||
: wizardData.prompt
|
||||
: '—'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Model: </Text>
|
||||
<Text>{wizardData.model ?? 'default'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Permissions: </Text>
|
||||
<Text>{wizardData.permissionMode ?? 'ask'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Folder: </Text>
|
||||
<Text>{wizardData.folder ?? 'current project'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Worktree: </Text>
|
||||
<Text>{wizardData.worktree ? 'yes' : 'no'}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold>Schedule: </Text>
|
||||
<Text>{schedule}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter to confirm, Esc to go back.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
50
src/components/scheduled-tasks/steps/TaskDescriptionStep.tsx
Normal file
50
src/components/scheduled-tasks/steps/TaskDescriptionStep.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { type ReactNode, useState } from 'react'
|
||||
import { Box, Text } from '../../../ink.js'
|
||||
import { useKeybinding } from '../../../hooks/useKeybinding.js'
|
||||
import TextInput from '../../TextInput.js'
|
||||
import { WizardDialogLayout } from '../../wizard/index.js'
|
||||
import { useWizard } from '../../wizard/useWizard.js'
|
||||
import type { ScheduledTaskWizardData } from '../types.js'
|
||||
|
||||
export function TaskDescriptionStep(): ReactNode {
|
||||
const { goNext, goBack, updateWizardData, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
const [value, setValue] = useState(wizardData.description ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useKeybinding('confirm:no', goBack, { context: 'Settings' })
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
setError('Description is required')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
updateWizardData({ description: trimmed })
|
||||
goNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Description">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Briefly describe what this scheduled task does.
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="e.g. Review yesterday's commits and flag anything concerning"
|
||||
/>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
23
src/components/scheduled-tasks/steps/TaskModelStep.tsx
Normal file
23
src/components/scheduled-tasks/steps/TaskModelStep.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { WizardDialogLayout } from '../../wizard/index.js'
|
||||
import { useWizard } from '../../wizard/useWizard.js'
|
||||
import { ModelSelector } from '../../agents/ModelSelector.js'
|
||||
import type { ScheduledTaskWizardData } from '../types.js'
|
||||
|
||||
export function TaskModelStep(): ReactNode {
|
||||
const { goNext, goBack, updateWizardData, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Model">
|
||||
<ModelSelector
|
||||
initialModel={wizardData.model}
|
||||
onComplete={(model) => {
|
||||
updateWizardData({ model })
|
||||
goNext()
|
||||
}}
|
||||
onCancel={goBack}
|
||||
/>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
50
src/components/scheduled-tasks/steps/TaskPromptStep.tsx
Normal file
50
src/components/scheduled-tasks/steps/TaskPromptStep.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { type ReactNode, useState } from 'react'
|
||||
import { Box, Text } from '../../../ink.js'
|
||||
import { useKeybinding } from '../../../hooks/useKeybinding.js'
|
||||
import TextInput from '../../TextInput.js'
|
||||
import { WizardDialogLayout } from '../../wizard/index.js'
|
||||
import { useWizard } from '../../wizard/useWizard.js'
|
||||
import type { ScheduledTaskWizardData } from '../types.js'
|
||||
|
||||
export function TaskPromptStep(): ReactNode {
|
||||
const { goNext, goBack, updateWizardData, wizardData } =
|
||||
useWizard<ScheduledTaskWizardData>()
|
||||
const [value, setValue] = useState(wizardData.prompt ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useKeybinding('confirm:no', goBack, { context: 'Settings' })
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
setError('Prompt is required')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
updateWizardData({ prompt: trimmed })
|
||||
goNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardDialogLayout subtitle="Prompt">
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Enter the prompt that will be sent to Claude when this task runs.
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="e.g. Look at the commits from the last 24 hours..."
|
||||
/>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</WizardDialogLayout>
|
||||
)
|
||||
}
|
||||
12
src/components/scheduled-tasks/types.ts
Normal file
12
src/components/scheduled-tasks/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type ScheduledTaskWizardData = {
|
||||
name?: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
permissionMode?: string
|
||||
folder?: string
|
||||
worktree?: boolean
|
||||
frequency?: string
|
||||
scheduledTime?: string
|
||||
cron?: string
|
||||
}
|
||||
@ -29,6 +29,7 @@ const SleepTool =
|
||||
const cronTools = feature('AGENT_TRIGGERS')
|
||||
? [
|
||||
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
|
||||
require('./tools/ScheduleCronTool/CronUpdateTool.js').CronUpdateTool,
|
||||
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
|
||||
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
|
||||
]
|
||||
|
||||
@ -27,6 +27,14 @@ const outputSchema = lazySchema(() =>
|
||||
prompt: z.string(),
|
||||
recurring: z.boolean().optional(),
|
||||
durable: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
folder: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
permissionMode: z.string().optional(),
|
||||
worktree: z.boolean().optional(),
|
||||
frequency: z.string().optional(),
|
||||
scheduledTime: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@ -74,6 +82,14 @@ export const CronListTool = buildTool({
|
||||
prompt: t.prompt,
|
||||
...(t.recurring ? { recurring: true } : {}),
|
||||
...(t.durable === false ? { durable: false } : {}),
|
||||
...(t.name ? { name: t.name } : {}),
|
||||
...(t.description ? { description: t.description } : {}),
|
||||
...(t.folder ? { folder: t.folder } : {}),
|
||||
...(t.model ? { model: t.model } : {}),
|
||||
...(t.permissionMode ? { permissionMode: t.permissionMode } : {}),
|
||||
...(t.worktree !== undefined ? { worktree: t.worktree } : {}),
|
||||
...(t.frequency ? { frequency: t.frequency } : {}),
|
||||
...(t.scheduledTime ? { scheduledTime: t.scheduledTime } : {}),
|
||||
}))
|
||||
return { data: { jobs } }
|
||||
},
|
||||
@ -86,7 +102,7 @@ export const CronListTool = buildTool({
|
||||
? output.jobs
|
||||
.map(
|
||||
j =>
|
||||
`${j.id} — ${j.humanSchedule}${j.recurring ? ' (recurring)' : ' (one-shot)'}${j.durable === false ? ' [session-only]' : ''}: ${truncate(j.prompt, 80, true)}`,
|
||||
`${j.id}${j.name ? ` [${j.name}]` : ''} — ${j.humanSchedule}${j.recurring ? ' (recurring)' : ' (one-shot)'}${j.durable === false ? ' [session-only]' : ''}${j.folder ? ` (${j.folder})` : ''}: ${truncate(j.prompt, 80, true)}`,
|
||||
)
|
||||
.join('\n')
|
||||
: 'No scheduled jobs.',
|
||||
|
||||
180
src/tools/ScheduleCronTool/CronUpdateTool.ts
Normal file
180
src/tools/ScheduleCronTool/CronUpdateTool.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { z } from 'zod/v4'
|
||||
import type { ValidationResult } from '../../Tool.js'
|
||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||
import { cronToHuman, parseCronExpression } from '../../utils/cron.js'
|
||||
import type { CronTask } from '../../utils/cronTasks.js'
|
||||
import {
|
||||
getCronFilePath,
|
||||
listAllCronTasks,
|
||||
nextCronRunMs,
|
||||
updateCronTask,
|
||||
} from '../../utils/cronTasks.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { semanticBoolean } from '../../utils/semanticBoolean.js'
|
||||
import { getTeammateContext } from '../../utils/teammateContext.js'
|
||||
import {
|
||||
buildCronUpdatePrompt,
|
||||
CRON_UPDATE_DESCRIPTION,
|
||||
CRON_UPDATE_TOOL_NAME,
|
||||
isDurableCronEnabled,
|
||||
isKairosCronEnabled,
|
||||
} from './prompt.js'
|
||||
import { renderUpdateResultMessage, renderUpdateToolUseMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
id: z.string().describe('Job ID returned by CronCreate.'),
|
||||
cron: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New 5-field cron expression in local time.'),
|
||||
prompt: z.string().optional().describe('New prompt to enqueue at each fire time.'),
|
||||
name: z.string().optional().describe('New task name.'),
|
||||
description: z.string().optional().describe('New task description.'),
|
||||
folder: z.string().optional().describe('New working directory path.'),
|
||||
model: z.string().optional().describe('New model to use.'),
|
||||
permissionMode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New permission mode: "ask" | "auto-accept" | "plan" | "bypass".'),
|
||||
worktree: semanticBoolean(z.boolean().optional()).describe(
|
||||
'New worktree setting.',
|
||||
),
|
||||
recurring: semanticBoolean(z.boolean().optional()).describe(
|
||||
'New recurring setting.',
|
||||
),
|
||||
frequency: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New frequency: "manual" | "hourly" | "daily" | "weekdays" | "weekly".'),
|
||||
scheduledTime: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New time string (e.g. "09:00").'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
humanSchedule: z.string(),
|
||||
updated: z.boolean(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
export type UpdateOutput = z.infer<OutputSchema>
|
||||
|
||||
export const CronUpdateTool = buildTool({
|
||||
name: CRON_UPDATE_TOOL_NAME,
|
||||
searchHint: 'update/edit a scheduled cron job',
|
||||
maxResultSizeChars: 100_000,
|
||||
shouldDefer: true,
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
isEnabled() {
|
||||
return isKairosCronEnabled()
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
return input.id
|
||||
},
|
||||
async description() {
|
||||
return CRON_UPDATE_DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return buildCronUpdatePrompt(isDurableCronEnabled())
|
||||
},
|
||||
getPath() {
|
||||
return getCronFilePath()
|
||||
},
|
||||
async validateInput(input): Promise<ValidationResult> {
|
||||
const tasks = await listAllCronTasks()
|
||||
const task = tasks.find(t => t.id === input.id)
|
||||
if (!task) {
|
||||
return {
|
||||
result: false,
|
||||
message: `No scheduled job with id '${input.id}'`,
|
||||
errorCode: 1,
|
||||
}
|
||||
}
|
||||
// Teammates may only update their own crons.
|
||||
const ctx = getTeammateContext()
|
||||
if (ctx && task.agentId !== ctx.agentId) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Cannot update cron job '${input.id}': owned by another agent`,
|
||||
errorCode: 2,
|
||||
}
|
||||
}
|
||||
// Validate new cron expression if provided.
|
||||
if (input.cron !== undefined) {
|
||||
if (!parseCronExpression(input.cron)) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Invalid cron expression '${input.cron}'. Expected 5 fields: M H DoM Mon DoW.`,
|
||||
errorCode: 3,
|
||||
}
|
||||
}
|
||||
if (nextCronRunMs(input.cron, Date.now()) === null) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Cron expression '${input.cron}' does not match any calendar date in the next year.`,
|
||||
errorCode: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result: true }
|
||||
},
|
||||
async call({ id, ...updates }) {
|
||||
// Only include fields that were explicitly provided (not undefined).
|
||||
// Use a whitelist of valid field names to discard typos.
|
||||
const VALID_FIELDS: ReadonlySet<string> = new Set([
|
||||
'cron', 'prompt', 'name', 'description', 'folder', 'model',
|
||||
'permissionMode', 'worktree', 'recurring', 'frequency', 'scheduledTime',
|
||||
])
|
||||
const cleanUpdates: Partial<Omit<CronTask, 'id' | 'createdAt'>> = {}
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v !== undefined && VALID_FIELDS.has(k)) {
|
||||
(cleanUpdates as Record<string, unknown>)[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Read the current cron before updating, so we can display the
|
||||
// effective schedule without a redundant disk read after the update.
|
||||
let currentCron = ''
|
||||
if (!updates.cron) {
|
||||
const tasks = await listAllCronTasks()
|
||||
const t = tasks.find(t => t.id === id)
|
||||
currentCron = t?.cron ?? ''
|
||||
}
|
||||
|
||||
const updated = await updateCronTask(id, cleanUpdates)
|
||||
const effectiveCron = updates.cron ?? currentCron
|
||||
const humanSchedule = effectiveCron
|
||||
? cronToHuman(effectiveCron)
|
||||
: 'unknown'
|
||||
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
humanSchedule,
|
||||
updated,
|
||||
},
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: output.updated
|
||||
? `Updated job ${output.id} (${output.humanSchedule}).`
|
||||
: `Job ${output.id} not found — it may have been deleted.`,
|
||||
}
|
||||
},
|
||||
renderToolUseMessage: renderUpdateToolUseMessage,
|
||||
renderToolResultMessage: renderUpdateResultMessage,
|
||||
} satisfies ToolDef<InputSchema, UpdateOutput>)
|
||||
@ -3,6 +3,7 @@ import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { Text } from '../../ink.js';
|
||||
import { truncate } from '../../utils/format.js';
|
||||
import type { CreateOutput } from './CronCreateTool.js';
|
||||
import type { UpdateOutput } from './CronUpdateTool.js';
|
||||
import type { DeleteOutput } from './CronDeleteTool.js';
|
||||
import type { ListOutput } from './CronListTool.js';
|
||||
|
||||
@ -23,6 +24,27 @@ export function renderCreateResultMessage(output: CreateOutput): React.ReactNode
|
||||
</MessageResponse>;
|
||||
}
|
||||
|
||||
// --- CronUpdate -------------------------------------------------------------
|
||||
|
||||
export function renderUpdateToolUseMessage(input: Partial<{
|
||||
id: string;
|
||||
}>): React.ReactNode {
|
||||
return input.id ?? '';
|
||||
}
|
||||
export function renderUpdateResultMessage(output: UpdateOutput): React.ReactNode {
|
||||
if (!output.updated) {
|
||||
return <MessageResponse>
|
||||
<Text dimColor>Job {output.id} not found</Text>
|
||||
</MessageResponse>;
|
||||
}
|
||||
return <MessageResponse>
|
||||
<Text>
|
||||
Updated <Text bold>{output.id}</Text>{' '}
|
||||
<Text dimColor>({output.humanSchedule})</Text>
|
||||
</Text>
|
||||
</MessageResponse>;
|
||||
}
|
||||
|
||||
// --- CronDelete -------------------------------------------------------------
|
||||
|
||||
export function renderDeleteToolUseMessage(input: Partial<{
|
||||
@ -51,7 +73,7 @@ export function renderListResultMessage(output: ListOutput): React.ReactNode {
|
||||
}
|
||||
return <MessageResponse>
|
||||
{output.jobs.map(j => <Text key={j.id}>
|
||||
<Text bold>{j.id}</Text> <Text dimColor>{j.humanSchedule}</Text>
|
||||
<Text bold>{j.id}</Text>{j.name ? <Text> [{j.name}]</Text> : null} <Text dimColor>{j.humanSchedule}</Text>{j.folder ? <Text dimColor> ({j.folder})</Text> : null}
|
||||
</Text>)}
|
||||
</MessageResponse>;
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ export function isDurableCronEnabled(): boolean {
|
||||
}
|
||||
|
||||
export const CRON_CREATE_TOOL_NAME = 'CronCreate'
|
||||
export const CRON_UPDATE_TOOL_NAME = 'CronUpdate'
|
||||
export const CRON_DELETE_TOOL_NAME = 'CronDelete'
|
||||
export const CRON_LIST_TOOL_NAME = 'CronList'
|
||||
|
||||
@ -120,6 +121,13 @@ Recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days — they fire one
|
||||
Returns a job ID you can pass to ${CRON_DELETE_TOOL_NAME}.`
|
||||
}
|
||||
|
||||
export const CRON_UPDATE_DESCRIPTION =
|
||||
'Update a scheduled cron job by ID — change its cron expression, prompt, name, description, folder, model, permission mode, worktree, or recurring setting.'
|
||||
|
||||
export function buildCronUpdatePrompt(durableEnabled: boolean): string {
|
||||
return `Update an existing cron job previously scheduled with ${CRON_CREATE_TOOL_NAME}. Pass the job ID and any fields you want to change — only the provided fields are updated, the rest stay the same.${durableEnabled ? ' Works for both durable and session-only jobs.' : ''}`
|
||||
}
|
||||
|
||||
export const CRON_DELETE_DESCRIPTION = 'Cancel a scheduled cron job by ID'
|
||||
export function buildCronDeletePrompt(durableEnabled: boolean): string {
|
||||
return durableEnabled
|
||||
|
||||
153
src/utils/__tests__/cronFrequency.test.ts
Normal file
153
src/utils/__tests__/cronFrequency.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
frequencyToCron,
|
||||
cronToFrequency,
|
||||
FREQUENCY_OPTIONS,
|
||||
} from '../cronFrequency.js'
|
||||
|
||||
describe('frequencyToCron', () => {
|
||||
test('manual returns empty string', () => {
|
||||
expect(frequencyToCron('manual')).toBe('')
|
||||
expect(frequencyToCron('manual', '09:00')).toBe('')
|
||||
})
|
||||
|
||||
test('hourly returns minute-only cron', () => {
|
||||
expect(frequencyToCron('hourly')).toBe('0 * * * *')
|
||||
expect(frequencyToCron('hourly', '15')).toBe('') // malformed: must be HH:MM
|
||||
expect(frequencyToCron('hourly', '0 * * * *')).toBe('') // malformed time
|
||||
expect(frequencyToCron('hourly', ':15')).toBe('') // malformed
|
||||
expect(frequencyToCron('hourly', '00:15')).toBe('15 * * * *')
|
||||
})
|
||||
|
||||
test('daily returns correct cron', () => {
|
||||
expect(frequencyToCron('daily', '09:00')).toBe('0 9 * * *')
|
||||
expect(frequencyToCron('daily', '14:30')).toBe('30 14 * * *')
|
||||
})
|
||||
|
||||
test('weekdays returns cron with 1-5 dow', () => {
|
||||
expect(frequencyToCron('weekdays', '09:00')).toBe('0 9 * * 1-5')
|
||||
expect(frequencyToCron('weekdays', '08:30')).toBe('30 8 * * 1-5')
|
||||
})
|
||||
|
||||
test('weekly returns cron with dow=1', () => {
|
||||
expect(frequencyToCron('weekly', '09:00')).toBe('0 9 * * 1')
|
||||
expect(frequencyToCron('weekly', '17:00')).toBe('0 17 * * 1')
|
||||
})
|
||||
|
||||
test('rejects invalid time formats', () => {
|
||||
expect(frequencyToCron('daily', '')).toBe('')
|
||||
expect(frequencyToCron('daily', '25:00')).toBe('')
|
||||
expect(frequencyToCron('daily', '09:60')).toBe('')
|
||||
expect(frequencyToCron('daily', 'abc')).toBe('')
|
||||
expect(frequencyToCron('daily', '9')).toBe('')
|
||||
expect(frequencyToCron('daily', ':30')).toBe('')
|
||||
})
|
||||
|
||||
test('defaults to 09:00 for missing time', () => {
|
||||
expect(frequencyToCron('daily')).toBe('0 9 * * *')
|
||||
expect(frequencyToCron('weekdays')).toBe('0 9 * * 1-5')
|
||||
expect(frequencyToCron('weekly')).toBe('0 9 * * 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cronToFrequency', () => {
|
||||
test('empty cron returns manual', () => {
|
||||
expect(cronToFrequency('')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency(' ')).toEqual({ frequency: 'manual' })
|
||||
})
|
||||
|
||||
test('invalid cron returns manual', () => {
|
||||
expect(cronToFrequency('not a cron')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('*')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('* *')).toEqual({ frequency: 'manual' })
|
||||
})
|
||||
|
||||
test('hourly pattern detected', () => {
|
||||
expect(cronToFrequency('7 * * * *')).toEqual({ frequency: 'hourly' })
|
||||
expect(cronToFrequency('0 * * * *')).toEqual({ frequency: 'hourly' })
|
||||
expect(cronToFrequency('30 * * * *')).toEqual({ frequency: 'hourly' })
|
||||
})
|
||||
|
||||
test('daily pattern detected', () => {
|
||||
expect(cronToFrequency('0 9 * * *')).toEqual({ frequency: 'daily', time: '09:00' })
|
||||
expect(cronToFrequency('30 14 * * *')).toEqual({ frequency: 'daily', time: '14:30' })
|
||||
})
|
||||
|
||||
test('weekdays pattern detected', () => {
|
||||
expect(cronToFrequency('0 9 * * 1-5')).toEqual({ frequency: 'weekdays', time: '09:00' })
|
||||
expect(cronToFrequency('30 8 * * 1-5')).toEqual({ frequency: 'weekdays', time: '08:30' })
|
||||
})
|
||||
|
||||
test('weekly pattern detected', () => {
|
||||
expect(cronToFrequency('0 9 * * 1')).toEqual({ frequency: 'weekly', time: '09:00' })
|
||||
expect(cronToFrequency('0 17 * * 1')).toEqual({ frequency: 'weekly', time: '17:00' })
|
||||
})
|
||||
|
||||
test('complex crons return manual', () => {
|
||||
expect(cronToFrequency('0 9 1-15 * *')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('0 9 * * 0,6')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('0 9 * 1,2 *')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('0 */2 * * *')).toEqual({ frequency: 'manual' })
|
||||
})
|
||||
|
||||
test('hourly when minute is step but hour is wildcard', () => {
|
||||
// */5 in minute + * in hour matches the first branch: hour === '*' && all wildcards
|
||||
expect(cronToFrequency('*/5 * * * *')).toEqual({ frequency: 'hourly' })
|
||||
})
|
||||
|
||||
test('out-of-range values return manual', () => {
|
||||
expect(cronToFrequency('60 25 * * *')).toEqual({ frequency: 'manual' })
|
||||
expect(cronToFrequency('99 99 * * *')).toEqual({ frequency: 'manual' })
|
||||
})
|
||||
|
||||
test('pads hour and minute correctly', () => {
|
||||
expect(cronToFrequency('5 9 * * *')).toEqual({ frequency: 'daily', time: '09:05' })
|
||||
expect(cronToFrequency('30 0 * * *')).toEqual({ frequency: 'daily', time: '00:30' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FREQUENCY_OPTIONS', () => {
|
||||
test('contains all expected frequencies', () => {
|
||||
const values = FREQUENCY_OPTIONS.map(o => o.value)
|
||||
expect(values).toContain('manual')
|
||||
expect(values).toContain('hourly')
|
||||
expect(values).toContain('daily')
|
||||
expect(values).toContain('weekdays')
|
||||
expect(values).toContain('weekly')
|
||||
})
|
||||
|
||||
test('all have labels', () => {
|
||||
for (const opt of FREQUENCY_OPTIONS) {
|
||||
expect(typeof opt.label).toBe('string')
|
||||
expect(opt.label.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip: frequency → cron → frequency', () => {
|
||||
test('daily round-trip preserves data', () => {
|
||||
const cron = frequencyToCron('daily', '09:00')
|
||||
const result = cronToFrequency(cron)
|
||||
expect(result).toEqual({ frequency: 'daily', time: '09:00' })
|
||||
})
|
||||
|
||||
test('weekdays round-trip preserves data', () => {
|
||||
const cron = frequencyToCron('weekdays', '08:30')
|
||||
const result = cronToFrequency(cron)
|
||||
expect(result).toEqual({ frequency: 'weekdays', time: '08:30' })
|
||||
})
|
||||
|
||||
test('weekly round-trip preserves data', () => {
|
||||
const cron = frequencyToCron('weekly', '17:00')
|
||||
const result = cronToFrequency(cron)
|
||||
expect(result).toEqual({ frequency: 'weekly', time: '17:00' })
|
||||
})
|
||||
|
||||
test('hourly round-trip preserves minute', () => {
|
||||
// frequencyToCron('hourly', '00:15') produces "15 * * * *"
|
||||
// cronToFrequency('15 * * * *') returns 'hourly' (minute is numeric, hour is '*')
|
||||
const cron = frequencyToCron('hourly', '00:15')
|
||||
const result = cronToFrequency(cron)
|
||||
expect(result).toEqual({ frequency: 'hourly' })
|
||||
})
|
||||
})
|
||||
204
src/utils/__tests__/cronTasks.test.ts
Normal file
204
src/utils/__tests__/cronTasks.test.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
// We'll test the updateCronTask by directly exercising the exported functions
|
||||
// through a temporary directory approach.
|
||||
// Note: These are integration tests that use actual filesystem operations.
|
||||
|
||||
describe('updateCronTask integration', () => {
|
||||
const tmpDir = join('/tmp', `cron-test-${randomUUID().slice(0, 8)}`)
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp project structure
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('CRON_FILE_REL constant is correct', async () => {
|
||||
// Import and verify the file relative path
|
||||
const { getCronFilePath } = await import('../cronTasks.js')
|
||||
const filePath = getCronFilePath(tmpDir)
|
||||
expect(filePath).toContain('.claude')
|
||||
expect(filePath).toContain('scheduled_tasks.json')
|
||||
})
|
||||
|
||||
test('getCronFilePath returns correct path', async () => {
|
||||
const { getCronFilePath } = await import('../cronTasks.js')
|
||||
const filePath = getCronFilePath(tmpDir)
|
||||
expect(filePath).toBe(join(tmpDir, '.claude', 'scheduled_tasks.json'))
|
||||
})
|
||||
|
||||
test('getCronFilePath uses project root when no dir provided', async () => {
|
||||
const { getCronFilePath } = await import('../cronTasks.js')
|
||||
// Without dir, should use getProjectRoot() which is process.cwd()
|
||||
// Just verify it returns a valid-looking path
|
||||
const filePath = getCronFilePath()
|
||||
expect(filePath).toContain('scheduled_tasks.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CronTaskMeta type coverage', () => {
|
||||
test('all UI fields are optional on CronTask', async () => {
|
||||
// Verify all new fields exist on the type by creating tasks with them
|
||||
const { addCronTask } = await import('../cronTasks.js')
|
||||
|
||||
// Create a task with all metadata fields (durable=true writes to disk in test dir)
|
||||
const tmpDir = join('/tmp', `cron-meta-test-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
const id = await addCronTask(
|
||||
'0 9 * * *',
|
||||
'test prompt',
|
||||
true, // recurring
|
||||
true, // durable (writes to disk)
|
||||
undefined, // agentId
|
||||
{
|
||||
name: 'test-name',
|
||||
description: 'test description',
|
||||
folder: '/test/folder',
|
||||
model: 'claude-opus-4-6',
|
||||
permissionMode: 'ask',
|
||||
worktree: false,
|
||||
frequency: 'daily',
|
||||
scheduledTime: '09:00',
|
||||
},
|
||||
)
|
||||
|
||||
expect(typeof id).toBe('string')
|
||||
expect(id.length).toBe(8) // Short ID
|
||||
|
||||
// Clean up
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCronTasks backward compatibility', () => {
|
||||
test('handles empty file', async () => {
|
||||
const { readCronTasks } = await import('../cronTasks.js')
|
||||
const tmpDir = join('/tmp', `cron-empty-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
const tasks = await readCronTasks(tmpDir)
|
||||
expect(Array.isArray(tasks)).toBe(true)
|
||||
expect(tasks.length).toBe(0)
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('skips malformed JSON', async () => {
|
||||
const { readCronTasks } = await import('../cronTasks.js')
|
||||
const tmpDir = join('/tmp', `cron-malformed-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
// Write malformed JSON
|
||||
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
|
||||
await writeFile(filePath, 'not valid json{{{')
|
||||
|
||||
const tasks = await readCronTasks(tmpDir)
|
||||
expect(tasks.length).toBe(0) // Malformed entries skipped
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('skips tasks with invalid cron strings', async () => {
|
||||
const { readCronTasks } = await import('../cronTasks.js')
|
||||
const tmpDir = join('/tmp', `cron-invalid-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
// Write task with invalid cron
|
||||
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
tasks: [
|
||||
{
|
||||
id: 'abcd1234',
|
||||
cron: 'invalid-cron',
|
||||
prompt: 'test',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const tasks = await readCronTasks(tmpDir)
|
||||
expect(tasks.length).toBe(0) // Invalid cron skipped
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('preserves new fields when reading', async () => {
|
||||
const { readCronTasks } = await import('../cronTasks.js')
|
||||
const tmpDir = join('/tmp', `cron-preserve-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
|
||||
const task = {
|
||||
id: 'abcd1234',
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'test prompt',
|
||||
createdAt: Date.now(),
|
||||
recurring: true,
|
||||
name: 'my-task',
|
||||
description: 'A test task',
|
||||
folder: '/project',
|
||||
model: 'claude-sonnet-4-6',
|
||||
permissionMode: 'bypass',
|
||||
worktree: true,
|
||||
frequency: 'daily',
|
||||
scheduledTime: '09:00',
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ tasks: [task] }))
|
||||
|
||||
const tasks = await readCronTasks(tmpDir)
|
||||
expect(tasks.length).toBe(1)
|
||||
expect(tasks[0].name).toBe('my-task')
|
||||
expect(tasks[0].description).toBe('A test task')
|
||||
expect(tasks[0].folder).toBe('/project')
|
||||
expect(tasks[0].model).toBe('claude-sonnet-4-6')
|
||||
expect(tasks[0].permissionMode).toBe('bypass')
|
||||
expect(tasks[0].worktree).toBe(true)
|
||||
expect(tasks[0].frequency).toBe('daily')
|
||||
expect(tasks[0].scheduledTime).toBe('09:00')
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeCronTasks strips runtime fields', () => {
|
||||
test('strips durable and agentId on write', async () => {
|
||||
const { readCronTasks, writeCronTasks } = await import('../cronTasks.js')
|
||||
const tmpDir = join('/tmp', `cron-strip-${randomUUID().slice(0, 8)}`)
|
||||
await mkdir(join(tmpDir, '.claude'), { recursive: true })
|
||||
|
||||
const taskWithRuntimeFields = {
|
||||
id: 'abcd1234',
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'test',
|
||||
createdAt: Date.now(),
|
||||
recurring: true,
|
||||
durable: true, // runtime-only, should be stripped
|
||||
agentId: 'agent-123', // runtime-only, should be stripped
|
||||
name: 'test-task', // new field, should be preserved
|
||||
}
|
||||
|
||||
await writeCronTasks([taskWithRuntimeFields as any], tmpDir)
|
||||
|
||||
// Read back and verify runtime fields are stripped
|
||||
const filePath = join(tmpDir, '.claude', 'scheduled_tasks.json')
|
||||
const { readFileSync } = await import('fs')
|
||||
const raw = readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
expect(parsed.tasks[0].durable).toBeUndefined()
|
||||
expect(parsed.tasks[0].agentId).toBeUndefined()
|
||||
expect(parsed.tasks[0].name).toBe('test-task')
|
||||
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
119
src/utils/cronFrequency.ts
Normal file
119
src/utils/cronFrequency.ts
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Converts between UI-friendly frequency settings and cron expressions.
|
||||
* Used by the ScheduledTaskWizard for human-readable schedule configuration.
|
||||
*/
|
||||
|
||||
/** Supported frequency values in the scheduled task UI. */
|
||||
export type Frequency = 'manual' | 'hourly' | 'daily' | 'weekdays' | 'weekly'
|
||||
|
||||
export const FREQUENCY_OPTIONS: { label: string; value: Frequency }[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Hourly', value: 'hourly' },
|
||||
{ label: 'Daily', value: 'daily' },
|
||||
{ label: 'Weekdays', value: 'weekdays' },
|
||||
{ label: 'Weekly', value: 'weekly' },
|
||||
]
|
||||
|
||||
/** Validate "HH:MM" or "H:MM" time string. Returns [hour, minute] or null. */
|
||||
function parseTime(time: string): { hour: number; minute: number } | null {
|
||||
if (!time || typeof time !== 'string') return null
|
||||
const [h, m] = time.trim().split(':')
|
||||
const hour = parseInt(h ?? '', 10)
|
||||
const minute = parseInt(m ?? '', 10)
|
||||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null
|
||||
if (hour < 0 || hour > 23) return null
|
||||
if (minute < 0 || minute > 59) return null
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a frequency + time to a 5-field cron expression.
|
||||
* Time format: "HH:MM" (24-hour). Defaults to "09:00" if not provided.
|
||||
*
|
||||
* - manual: no cron (returns empty string — task runs on demand)
|
||||
* - hourly: "0 * * * *"
|
||||
* - daily + "09:00": "0 9 * * *"
|
||||
* - weekdays + "09:00": "0 9 * * 1-5"
|
||||
* - weekly + "09:00": "0 9 * * 1" (Monday)
|
||||
*/
|
||||
export function frequencyToCron(frequency: Frequency, time?: string): string {
|
||||
if (frequency === 'manual') return ''
|
||||
|
||||
const parsed = parseTime(time ?? '09:00')
|
||||
if (!parsed) return '' // malformed time → no-op
|
||||
const { hour, minute } = parsed
|
||||
|
||||
switch (frequency) {
|
||||
case 'hourly':
|
||||
return `${minute} * * * *`
|
||||
case 'daily':
|
||||
return `${minute} ${hour} * * *`
|
||||
case 'weekdays':
|
||||
return `${minute} ${hour} * * 1-5`
|
||||
case 'weekly':
|
||||
return `${minute} ${hour} * * 1`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reverse: guess the frequency + time from a cron expression.
|
||||
* Returns the closest match; complex crons fall back to { frequency: 'manual' }.
|
||||
*/
|
||||
export function cronToFrequency(cron: string): {
|
||||
frequency: Frequency
|
||||
time?: string
|
||||
} {
|
||||
if (!cron || cron.trim() === '') return { frequency: 'manual' }
|
||||
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return { frequency: 'manual' }
|
||||
|
||||
const [minute, hour, dom, month, dow] = parts
|
||||
|
||||
// Hourly: "N * * * *"
|
||||
if (hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
||||
return { frequency: 'hourly' }
|
||||
}
|
||||
|
||||
// Check if it's a specific hour+minute pattern
|
||||
if (
|
||||
dom === '*' &&
|
||||
month === '*' &&
|
||||
isNumber(minute!) &&
|
||||
isNumber(hour!)
|
||||
) {
|
||||
const h = parseInt(hour!, 10)
|
||||
const m = parseInt(minute!, 10)
|
||||
// Guard against out-of-range values (e.g. malformed cron in existing data)
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) {
|
||||
return { frequency: 'manual' }
|
||||
}
|
||||
const timeStr = `${pad(h)}:${pad(m)}`
|
||||
|
||||
// Weekdays: "M H * * 1-5"
|
||||
if (dow === '1-5') {
|
||||
return { frequency: 'weekdays', time: timeStr }
|
||||
}
|
||||
|
||||
// Weekly: "M H * * 1"
|
||||
if (dow === '1') {
|
||||
return { frequency: 'weekly', time: timeStr }
|
||||
}
|
||||
|
||||
// Daily: "M H * * *"
|
||||
if (dow === '*') {
|
||||
return { frequency: 'daily', time: timeStr }
|
||||
}
|
||||
}
|
||||
|
||||
// Can't map to a simple frequency
|
||||
return { frequency: 'manual' }
|
||||
}
|
||||
|
||||
function isNumber(s: string): boolean {
|
||||
return /^\d+$/.test(s)
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
@ -18,6 +18,7 @@ import {
|
||||
getProjectRoot,
|
||||
getSessionCronTasks,
|
||||
removeSessionCronTasks,
|
||||
setScheduledTasksEnabled,
|
||||
} from '../bootstrap/state.js'
|
||||
import { computeNextCronRun, parseCronExpression } from './cron.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
@ -57,8 +58,8 @@ export type CronTask = {
|
||||
permanent?: boolean
|
||||
/**
|
||||
* Runtime-only flag. false → session-scoped (never written to disk).
|
||||
* File-backed tasks leave this undefined; writeCronTasks strips it so
|
||||
* the on-disk shape stays { id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }.
|
||||
* File-backed tasks leave this undefined; writeCronTasks strips it
|
||||
* (along with agentId) so neither appears in the on-disk JSON.
|
||||
*/
|
||||
durable?: boolean
|
||||
/**
|
||||
@ -67,6 +68,34 @@ export type CronTask = {
|
||||
* REPL's. Never written to disk (teammate crons are always session-only).
|
||||
*/
|
||||
agentId?: string
|
||||
/** 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 metadata fields for scheduled tasks (UI-driven creation). */
|
||||
export type CronTaskMeta = {
|
||||
name?: string
|
||||
description?: string
|
||||
folder?: string
|
||||
model?: string
|
||||
permissionMode?: string
|
||||
worktree?: boolean
|
||||
frequency?: string
|
||||
scheduledTime?: string
|
||||
}
|
||||
|
||||
type CronFile = { tasks: CronTask[] }
|
||||
@ -134,6 +163,20 @@ export async function readCronTasks(dir?: string): Promise<CronTask[]> {
|
||||
: {}),
|
||||
...(t.recurring ? { recurring: true } : {}),
|
||||
...(t.permanent ? { permanent: true } : {}),
|
||||
...(typeof t.name === 'string' ? { name: t.name } : {}),
|
||||
...(typeof t.description === 'string'
|
||||
? { description: t.description }
|
||||
: {}),
|
||||
...(typeof t.folder === 'string' ? { folder: t.folder } : {}),
|
||||
...(typeof t.model === 'string' ? { model: t.model } : {}),
|
||||
...(typeof t.permissionMode === 'string'
|
||||
? { permissionMode: t.permissionMode }
|
||||
: {}),
|
||||
...(typeof t.worktree === 'boolean' ? { worktree: t.worktree } : {}),
|
||||
...(typeof t.frequency === 'string' ? { frequency: t.frequency } : {}),
|
||||
...(typeof t.scheduledTime === 'string'
|
||||
? { scheduledTime: t.scheduledTime }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@ -168,11 +211,10 @@ export async function writeCronTasks(
|
||||
): Promise<void> {
|
||||
const root = dir ?? getProjectRoot()
|
||||
await mkdir(join(root, '.claude'), { recursive: true })
|
||||
// Strip the runtime-only `durable` flag — everything on disk is durable
|
||||
// by definition, and keeping the flag out means readCronTasks() naturally
|
||||
// yields durable: undefined without having to set it explicitly.
|
||||
// Strip runtime-only flags — everything on disk is durable by definition,
|
||||
// and agentId is session-scoped (teammates don't persist across sessions).
|
||||
const body: CronFile = {
|
||||
tasks: tasks.map(({ durable: _durable, ...rest }) => rest),
|
||||
tasks: tasks.map(({ durable: _durable, agentId: _agentId, ...rest }) => rest),
|
||||
}
|
||||
await writeFile(
|
||||
getCronFilePath(root),
|
||||
@ -197,16 +239,27 @@ export async function addCronTask(
|
||||
recurring: boolean,
|
||||
durable: boolean,
|
||||
agentId?: string,
|
||||
meta?: CronTaskMeta,
|
||||
): Promise<string> {
|
||||
// Short ID — 8 hex chars is plenty for MAX_JOBS=50, avoids slice/prefix
|
||||
// juggling between the tool layer (shows short IDs) and disk.
|
||||
const id = randomUUID().slice(0, 8)
|
||||
const task = {
|
||||
const task: CronTask = {
|
||||
id,
|
||||
cron,
|
||||
prompt,
|
||||
createdAt: Date.now(),
|
||||
...(recurring ? { recurring: true } : {}),
|
||||
...(meta?.name ? { name: meta.name } : {}),
|
||||
...(meta?.description ? { description: meta.description } : {}),
|
||||
...(meta?.folder ? { folder: meta.folder } : {}),
|
||||
...(meta?.model ? { model: meta.model } : {}),
|
||||
...(meta?.permissionMode
|
||||
? { permissionMode: meta.permissionMode }
|
||||
: {}),
|
||||
...(meta?.worktree !== undefined ? { worktree: meta.worktree } : {}),
|
||||
...(meta?.frequency ? { frequency: meta.frequency } : {}),
|
||||
...(meta?.scheduledTime ? { scheduledTime: meta.scheduledTime } : {}),
|
||||
}
|
||||
if (!durable) {
|
||||
addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) })
|
||||
@ -295,6 +348,42 @@ export async function listAllCronTasks(dir?: string): Promise<CronTask[]> {
|
||||
return [...fileTasks, ...sessionTasks]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fields of an existing task by id. Searches both the in-memory
|
||||
* session store and the on-disk file. Returns true if the task was found
|
||||
* and updated, false if no task with that id exists.
|
||||
*
|
||||
* When `dir` is undefined (REPL path), tries the session store first;
|
||||
* if the id isn't there, falls through to disk.
|
||||
*/
|
||||
export async function updateCronTask(
|
||||
id: string,
|
||||
updates: Partial<Omit<CronTask, 'id' | 'createdAt'>>,
|
||||
dir?: string,
|
||||
): Promise<boolean> {
|
||||
// Try session store first (REPL path only).
|
||||
if (dir === undefined) {
|
||||
const sessionTasks = getSessionCronTasks()
|
||||
const sessionTask = sessionTasks.find(t => t.id === id)
|
||||
if (sessionTask) {
|
||||
Object.assign(sessionTask, updates)
|
||||
// Re-signal the scheduler so it recalculates nextFireAt from the
|
||||
// potentially-changed cron expression. The scheduler polls this flag.
|
||||
setScheduledTasksEnabled(true)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to disk.
|
||||
const tasks = await readCronTasks(dir)
|
||||
const task = tasks.find(t => t.id === id)
|
||||
if (!task) return false
|
||||
|
||||
Object.assign(task, updates)
|
||||
await writeCronTasks(tasks, dir)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Next fire time in epoch ms for a cron string, strictly after `fromMs`.
|
||||
* Returns null if invalid or no match in the next 366 days.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user