mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 考虑)
|
- 多 API 格式支持(OpenAI 兼容等,V2 考虑)
|
||||||
- Provider 导入/导出
|
- Provider 导入/导出
|
||||||
- 自动故障转移(failover)
|
- 自动故障转移(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.
|
* instead of the main REPL command queue. Session-only — never written to disk.
|
||||||
*/
|
*/
|
||||||
agentId?: string
|
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[] {
|
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')
|
const cronTools = feature('AGENT_TRIGGERS')
|
||||||
? [
|
? [
|
||||||
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
|
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
|
||||||
|
require('./tools/ScheduleCronTool/CronUpdateTool.js').CronUpdateTool,
|
||||||
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
|
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
|
||||||
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
|
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
|
||||||
]
|
]
|
||||||
|
|||||||
@ -27,6 +27,14 @@ const outputSchema = lazySchema(() =>
|
|||||||
prompt: z.string(),
|
prompt: z.string(),
|
||||||
recurring: z.boolean().optional(),
|
recurring: z.boolean().optional(),
|
||||||
durable: 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,
|
prompt: t.prompt,
|
||||||
...(t.recurring ? { recurring: true } : {}),
|
...(t.recurring ? { recurring: true } : {}),
|
||||||
...(t.durable === false ? { durable: false } : {}),
|
...(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 } }
|
return { data: { jobs } }
|
||||||
},
|
},
|
||||||
@ -86,7 +102,7 @@ export const CronListTool = buildTool({
|
|||||||
? output.jobs
|
? output.jobs
|
||||||
.map(
|
.map(
|
||||||
j =>
|
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')
|
.join('\n')
|
||||||
: 'No scheduled jobs.',
|
: '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 { Text } from '../../ink.js';
|
||||||
import { truncate } from '../../utils/format.js';
|
import { truncate } from '../../utils/format.js';
|
||||||
import type { CreateOutput } from './CronCreateTool.js';
|
import type { CreateOutput } from './CronCreateTool.js';
|
||||||
|
import type { UpdateOutput } from './CronUpdateTool.js';
|
||||||
import type { DeleteOutput } from './CronDeleteTool.js';
|
import type { DeleteOutput } from './CronDeleteTool.js';
|
||||||
import type { ListOutput } from './CronListTool.js';
|
import type { ListOutput } from './CronListTool.js';
|
||||||
|
|
||||||
@ -23,6 +24,27 @@ export function renderCreateResultMessage(output: CreateOutput): React.ReactNode
|
|||||||
</MessageResponse>;
|
</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 -------------------------------------------------------------
|
// --- CronDelete -------------------------------------------------------------
|
||||||
|
|
||||||
export function renderDeleteToolUseMessage(input: Partial<{
|
export function renderDeleteToolUseMessage(input: Partial<{
|
||||||
@ -51,7 +73,7 @@ export function renderListResultMessage(output: ListOutput): React.ReactNode {
|
|||||||
}
|
}
|
||||||
return <MessageResponse>
|
return <MessageResponse>
|
||||||
{output.jobs.map(j => <Text key={j.id}>
|
{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>)}
|
</Text>)}
|
||||||
</MessageResponse>;
|
</MessageResponse>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export function isDurableCronEnabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CRON_CREATE_TOOL_NAME = 'CronCreate'
|
export const CRON_CREATE_TOOL_NAME = 'CronCreate'
|
||||||
|
export const CRON_UPDATE_TOOL_NAME = 'CronUpdate'
|
||||||
export const CRON_DELETE_TOOL_NAME = 'CronDelete'
|
export const CRON_DELETE_TOOL_NAME = 'CronDelete'
|
||||||
export const CRON_LIST_TOOL_NAME = 'CronList'
|
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}.`
|
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 const CRON_DELETE_DESCRIPTION = 'Cancel a scheduled cron job by ID'
|
||||||
export function buildCronDeletePrompt(durableEnabled: boolean): string {
|
export function buildCronDeletePrompt(durableEnabled: boolean): string {
|
||||||
return durableEnabled
|
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,
|
getProjectRoot,
|
||||||
getSessionCronTasks,
|
getSessionCronTasks,
|
||||||
removeSessionCronTasks,
|
removeSessionCronTasks,
|
||||||
|
setScheduledTasksEnabled,
|
||||||
} from '../bootstrap/state.js'
|
} from '../bootstrap/state.js'
|
||||||
import { computeNextCronRun, parseCronExpression } from './cron.js'
|
import { computeNextCronRun, parseCronExpression } from './cron.js'
|
||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
@ -57,8 +58,8 @@ export type CronTask = {
|
|||||||
permanent?: boolean
|
permanent?: boolean
|
||||||
/**
|
/**
|
||||||
* Runtime-only flag. false → session-scoped (never written to disk).
|
* Runtime-only flag. false → session-scoped (never written to disk).
|
||||||
* File-backed tasks leave this undefined; writeCronTasks strips it so
|
* File-backed tasks leave this undefined; writeCronTasks strips it
|
||||||
* the on-disk shape stays { id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }.
|
* (along with agentId) so neither appears in the on-disk JSON.
|
||||||
*/
|
*/
|
||||||
durable?: boolean
|
durable?: boolean
|
||||||
/**
|
/**
|
||||||
@ -67,6 +68,34 @@ export type CronTask = {
|
|||||||
* REPL's. Never written to disk (teammate crons are always session-only).
|
* REPL's. Never written to disk (teammate crons are always session-only).
|
||||||
*/
|
*/
|
||||||
agentId?: string
|
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[] }
|
type CronFile = { tasks: CronTask[] }
|
||||||
@ -134,6 +163,20 @@ export async function readCronTasks(dir?: string): Promise<CronTask[]> {
|
|||||||
: {}),
|
: {}),
|
||||||
...(t.recurring ? { recurring: true } : {}),
|
...(t.recurring ? { recurring: true } : {}),
|
||||||
...(t.permanent ? { permanent: 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
|
return out
|
||||||
@ -168,11 +211,10 @@ export async function writeCronTasks(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const root = dir ?? getProjectRoot()
|
const root = dir ?? getProjectRoot()
|
||||||
await mkdir(join(root, '.claude'), { recursive: true })
|
await mkdir(join(root, '.claude'), { recursive: true })
|
||||||
// Strip the runtime-only `durable` flag — everything on disk is durable
|
// Strip runtime-only flags — everything on disk is durable by definition,
|
||||||
// by definition, and keeping the flag out means readCronTasks() naturally
|
// and agentId is session-scoped (teammates don't persist across sessions).
|
||||||
// yields durable: undefined without having to set it explicitly.
|
|
||||||
const body: CronFile = {
|
const body: CronFile = {
|
||||||
tasks: tasks.map(({ durable: _durable, ...rest }) => rest),
|
tasks: tasks.map(({ durable: _durable, agentId: _agentId, ...rest }) => rest),
|
||||||
}
|
}
|
||||||
await writeFile(
|
await writeFile(
|
||||||
getCronFilePath(root),
|
getCronFilePath(root),
|
||||||
@ -197,16 +239,27 @@ export async function addCronTask(
|
|||||||
recurring: boolean,
|
recurring: boolean,
|
||||||
durable: boolean,
|
durable: boolean,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
|
meta?: CronTaskMeta,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Short ID — 8 hex chars is plenty for MAX_JOBS=50, avoids slice/prefix
|
// Short ID — 8 hex chars is plenty for MAX_JOBS=50, avoids slice/prefix
|
||||||
// juggling between the tool layer (shows short IDs) and disk.
|
// juggling between the tool layer (shows short IDs) and disk.
|
||||||
const id = randomUUID().slice(0, 8)
|
const id = randomUUID().slice(0, 8)
|
||||||
const task = {
|
const task: CronTask = {
|
||||||
id,
|
id,
|
||||||
cron,
|
cron,
|
||||||
prompt,
|
prompt,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
...(recurring ? { recurring: true } : {}),
|
...(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) {
|
if (!durable) {
|
||||||
addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) })
|
addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) })
|
||||||
@ -295,6 +348,42 @@ export async function listAllCronTasks(dir?: string): Promise<CronTask[]> {
|
|||||||
return [...fileTasks, ...sessionTasks]
|
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`.
|
* 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.
|
* Returns null if invalid or no match in the next 366 days.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user