@zhushanwen/pi-scheduler 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -0
- package/package.json +4 -3
- package/src/__tests__/backend.test.ts +63 -0
- package/src/__tests__/commands.test.ts +57 -60
- package/src/__tests__/cron.test.ts +1 -2
- package/src/__tests__/format.test.ts +35 -0
- package/src/__tests__/parsing.test.ts +29 -0
- package/src/__tests__/property.test.ts +131 -0
- package/src/__tests__/runtime.test.ts +168 -48
- package/src/__tests__/sdk-contract.test.ts +13 -7
- package/src/__tests__/service.test.ts +185 -0
- package/src/__tests__/store-roundtrip.test.ts +218 -0
- package/src/__tests__/tool.test.ts +38 -23
- package/src/backend.ts +114 -0
- package/src/commands.ts +28 -34
- package/src/format.ts +6 -4
- package/src/index.ts +30 -22
- package/src/parsing.ts +19 -1
- package/src/runtime.ts +79 -49
- package/src/service.ts +144 -0
- package/src/store.ts +1 -0
- package/src/tool.ts +50 -74
- package/src/types.ts +3 -5
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# scheduler
|
|
2
|
+
|
|
3
|
+
定时任务调度扩展:按 duration(`5m` / `2h` / `1d`)间隔或 cron 表达式,在指定时间向 agent 注入消息。支持一次性提醒(once)、强制触发(force)、过期策略(expires)与持久化(重启后任务保留)。
|
|
4
|
+
|
|
5
|
+
## 简介与安装
|
|
6
|
+
|
|
7
|
+
pi-scheduler 是 xyz-agent 的 **mandatory 扩展**(`packages/shared/src/mandatory-extensions.json`,tier: `feature`)——xyz-agent 启动时自动安装并启用,无需手动操作。
|
|
8
|
+
|
|
9
|
+
独立 pi 环境手动安装:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @zhushanwen/pi-scheduler
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
安装后扩展在 pi 会话启动时自动装配(见下节),无需额外配置。
|
|
16
|
+
|
|
17
|
+
## 激活方式
|
|
18
|
+
|
|
19
|
+
扩展 factory(`src/index.ts`)监听 pi 的 session 生命周期事件,在 `session_start` 时装配完整调用链:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
session_start
|
|
23
|
+
└─ PiSchedulerBackend(FS 读写 + pi.sendMessage + 时间源)
|
|
24
|
+
└─ SchedulerRuntime(内存态 + 30s tick 调度 + 限流)
|
|
25
|
+
└─ SchedulerService(tool/command 唯一业务入口)
|
|
26
|
+
├─ runtime.loadTasks(backend.loadTasks()) ← 从磁盘恢复任务
|
|
27
|
+
├─ runtime.startScheduler() ← 启动 tick
|
|
28
|
+
└─ 注册 scheduler widget(30s 刷新)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`session_shutdown` 时执行 `runtime.persistSync()`(立即写盘)并停止 tick。任务状态因此随 session 持久化:关闭重开 session 后任务仍存在、到期仍会触发。
|
|
32
|
+
|
|
33
|
+
## /schedule 命令用法
|
|
34
|
+
|
|
35
|
+
注册为 `/schedule` 命令。无参数时显示 usage;第一个参数匹配子命令关键词则走子命令分支,否则尝试创建任务。
|
|
36
|
+
|
|
37
|
+
### 子命令
|
|
38
|
+
|
|
39
|
+
| 子命令 | 行为 |
|
|
40
|
+
|--------|------|
|
|
41
|
+
| `/schedule list` | 列出所有任务(id、名称、调度、下次执行时间) |
|
|
42
|
+
| `/schedule on <id>` | 启用任务 |
|
|
43
|
+
| `/schedule off <id>` | 停用任务(推荐临时暂停用 off,不用 rm) |
|
|
44
|
+
| `/schedule rm <id>` | 删除任务 |
|
|
45
|
+
| `/schedule run <id>` | 立即执行任务 |
|
|
46
|
+
| `/schedule once <schedule> <prompt>` | 创建一次性提醒(kind=once) |
|
|
47
|
+
| `/schedule cron <expression> <prompt>` | 创建 cron 任务 |
|
|
48
|
+
|
|
49
|
+
任务 id 由 8 位 hex 自动生成,`list` 后从输出中获取。
|
|
50
|
+
|
|
51
|
+
### 引号转义
|
|
52
|
+
|
|
53
|
+
参数用 shell 风格引号解析(`tokenizeQuoted`,`src/commands.ts`):
|
|
54
|
+
|
|
55
|
+
- 单引号 `'...'` 或双引号 `"..."` 内的内容作为一个 token,引号字符本身被剥离
|
|
56
|
+
- 含空格的多词参数(cron 表达式、prompt)**必须加引号**,否则会被拆成多个 token
|
|
57
|
+
|
|
58
|
+
例如 cron 表达式 `0 9 * * 1-5` 含空格,必须写成 `'/schedule cron '0 9 * * 1-5' standup'`;prompt `check build` 同理写成 `'/schedule 5m 'check build''`。
|
|
59
|
+
|
|
60
|
+
### 子命令补全
|
|
61
|
+
|
|
62
|
+
输入 `/schedule ` 后 Tab 补全子命令关键词(list/on/off/rm/run/once/cron);`on`/`off`/`rm`/`run` 之后补全当前任务 id。
|
|
63
|
+
|
|
64
|
+
## schedule 语法
|
|
65
|
+
|
|
66
|
+
### duration(间隔调度)
|
|
67
|
+
|
|
68
|
+
`<数字><单位>`,数字与单位间可有空格,**大小写不敏感**:
|
|
69
|
+
|
|
70
|
+
| 单位 | 含义 | 乘数 |
|
|
71
|
+
|------|------|------|
|
|
72
|
+
| `s` / `sec` / `second` / `seconds` | 秒 | 1,000 ms |
|
|
73
|
+
| `m` / `min` / `minute` / `minutes` | 分 | 60,000 ms |
|
|
74
|
+
| `h` / `hr` / `hour` / `hours` | 时 | 3,600,000 ms |
|
|
75
|
+
| `d` / `day` / `days` | 天 | 86,400,000 ms |
|
|
76
|
+
|
|
77
|
+
例如:`5m`、`2h`、`1d`、`30seconds`、`2hours`。非法输入(裸数字、未知单位、空串、负值)解析失败 → 创建任务报 `INVALID_SCHEDULE`。
|
|
78
|
+
|
|
79
|
+
### cron(时间点调度)
|
|
80
|
+
|
|
81
|
+
标准 cron 表达式,支持 5 字段与 6 字段:
|
|
82
|
+
|
|
83
|
+
- **5 字段**(分 时 日 月 周):自动补 `0` 秒字段(如 `0 9 * * 1-5` → `0 0 9 * * 1-5`)
|
|
84
|
+
- **6 字段**(秒 分 时 日 月 周):原样使用
|
|
85
|
+
|
|
86
|
+
**含空格自动走 cron 分支**:schedule 输入中不含空格 → 按 duration 解析;含空格 → 按 cron 解析。因此 cron 表达式必须包含空格(正常写法天然如此),duration 不得含空格。
|
|
87
|
+
|
|
88
|
+
## 选项语义
|
|
89
|
+
|
|
90
|
+
`schedule` tool 的 `kind` / `name` / `expires` / `force` 参数(`/schedule` 命令的 `once`/`cron` 前缀对应 kind):
|
|
91
|
+
|
|
92
|
+
| 选项 | 取值 | 语义 |
|
|
93
|
+
|------|------|------|
|
|
94
|
+
| `kind` | `recurring`(默认)/ `once` | recurring 每次触发后按 schedule 重算下次时间;once 触发一次后自动删除 |
|
|
95
|
+
| `name` | 字符串 | 任务可读名称,缺省从 prompt 自动生成(≤30 字原样,超长截前 27 字加省略号) |
|
|
96
|
+
| `expires` | duration 字符串 / `never` | recurring 任务的过期时间:`now + duration`;`never` 永不过期;缺省 7 天。**once 任务不设过期**(触发即删,expires 忽略) |
|
|
97
|
+
| `force` | `true` / `false`(默认) | `true` 时即使 agent 忙(非 idle 或有 pending 消息)也强制 dispatch;`false` 时忙则延迟到下次 tick |
|
|
98
|
+
|
|
99
|
+
## 示例
|
|
100
|
+
|
|
101
|
+
**recurring 间隔任务**(每 5 分钟检查构建):
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
/schedule 5m 'check build'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**一次性提醒**(10 秒后提醒):
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
/schedule once 10s remind
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**cron 任务**(工作日早 9 点站会):
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
/schedule cron '0 9 * * 1-5' standup
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**force 立即触发**(tool 调用:即使忙也执行):
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{"prompt": "deploy staging", "schedule": "*/10 * * * *", "force": true}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**永不过期**(tool 调用:长期任务不设 7 天默认过期):
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{"prompt": "monthly report", "schedule": "1d", "expires": "never"}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 限制与运行时行为
|
|
132
|
+
|
|
133
|
+
| 限制/行为 | 值 | 说明 |
|
|
134
|
+
|-----------|-----|------|
|
|
135
|
+
| 任务上限 | **50**(`MAX_TASKS`) | 超过抛 `Task limit reached (50)`,需先删除任务 |
|
|
136
|
+
| 触发频率上限 | **6 次/分钟**(`RATE_LIMIT_PER_MINUTE`) | 滑动 60s 窗口。`/schedule run` 超限返回 `DISPATCH_SKIPPED`;tick 自动 dispatch 超限静默跳过 |
|
|
137
|
+
| tick 间隔 | **30s**(`TICK_INTERVAL_MS`) | 到期任务在下一个 tick 被 dispatch;实际触发时间可能比计划晚最多 30s |
|
|
138
|
+
| 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
|
|
139
|
+
| once 任务 | 触发后自动删除 | 不参与后续调度 |
|
|
140
|
+
| cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
|
|
141
|
+
| persist 失败 | `console.warn` + 内存态保留 + `lastError='persist failed'` | 不打断调度,下次 tick 继续尝试 |
|
|
142
|
+
| 忙时 dispatch | 非 force 任务在 agent 忙(非 idle / 有 pending 消息)时跳过,延迟到下次 tick | force=true 可绕过 |
|
|
143
|
+
| history | 保留最近 **20** 条执行记录 | 超出丢弃最旧 |
|
|
144
|
+
| 持久化 | 每次变更写盘(session_shutdown 强制同步写) | 重启后任务保留 |
|
|
145
|
+
|
|
146
|
+
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / busy / rate-limited → `DISPATCH_SKIPPED`(message 含 `busy, disabled, or rate-limited`)。
|
|
147
|
+
|
|
148
|
+
## 数据存储位置
|
|
149
|
+
|
|
150
|
+
任务存储为单个 JSON 文件,按 workspace 路径隔离(不同 cwd 存不同文件):
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
~/.pi/agent/scheduler/<root>/<segments>/scheduler.json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `<root>`:路径根(如 `/` → `root`)
|
|
157
|
+
- `<segments>`:cwd 相对根的路径段(如 `/Users/me/project` → `Users/me/project`)
|
|
158
|
+
|
|
159
|
+
删除文件即清除全部任务;损坏的 JSON 自动降级为空 store 并 `console.warn`。
|
|
160
|
+
|
|
161
|
+
## 开发
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pnpm test # 运行全部 vitest 测试(等价 npx vitest run)
|
|
165
|
+
npx vitest run src/__tests__/<file>.test.ts # 单个文件
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
测试策略:
|
|
169
|
+
|
|
170
|
+
- **依赖反转**:`SchedulerRuntime` 只依赖 `SchedulerBackend` 接口(`sendMessage` / `persist` / `now`),不触碰 FS/pi。测试注入 `MockSchedulerBackend`(`src/backend.ts` 同文件 export)实现零副作用测试:`sentMessages` 记录发送、`persistedStores` 记录持久化、`persistError` 注入失败、`nowValue` 固定时间源
|
|
171
|
+
- **纯函数**:`parseDuration` / `formatDuration` / `parseSchedule` / `computeNextRunAt` / `computeNextRuns`(`src/parsing.ts`)无副作用,可直接断言
|
|
172
|
+
- **property-based**:`src/__tests__/property.test.ts` 用 fast-check 生成随机组合验证不变量(interval 精确、duration round-trip、format↔parse 一致性),生成器范围契约见该文件注释
|
|
173
|
+
- **round-trip**:`store.test.ts`(mock fs)验证路径与 GC;`store-roundtrip.test.ts`(真实 fs,os.tmpdir 隔离)验证 load 白名单字段(lastError/lastStatus/lastRunAt/expiresAt/force/history)持久化往返
|
|
174
|
+
|
|
175
|
+
扩展内部结构:`backend.ts`(后端抽象)→ `runtime.ts`(调度核心)→ `service.ts`(业务入口)→ `tool.ts`/`commands.ts`(tool 与 /schedule 命令适配层)→ `widget.ts`(状态栏 widget)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"pi": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"pi-package"
|
|
14
14
|
],
|
|
15
15
|
"devDependencies": {
|
|
16
|
+
"fast-check": "^4.9.0",
|
|
16
17
|
"vitest": "^4.1.8"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
@@ -22,8 +23,8 @@
|
|
|
22
23
|
],
|
|
23
24
|
"peerDependencies": {
|
|
24
25
|
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
26
|
+
"croner": "^9.0.0",
|
|
27
|
+
"typebox": "*"
|
|
27
28
|
},
|
|
28
29
|
"peerDependenciesMeta": {
|
|
29
30
|
"@earendil-works/pi-coding-agent": {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
4
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
5
|
+
import type { SchedulerStore } from '../types.js'
|
|
6
|
+
|
|
7
|
+
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
8
|
+
|
|
9
|
+
describe('MockSchedulerBackend', () => {
|
|
10
|
+
it('records sendMessage calls', async () => {
|
|
11
|
+
const backend = new MockSchedulerBackend()
|
|
12
|
+
await backend.sendMessage(
|
|
13
|
+
{ content: 'hi', customType: 'pi-scheduler:dispatched', display: true },
|
|
14
|
+
{ deliverAs: 'followUp', triggerTurn: true },
|
|
15
|
+
)
|
|
16
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
17
|
+
expect(backend.sentMessages[0]!.msg).toEqual({
|
|
18
|
+
content: 'hi',
|
|
19
|
+
customType: 'pi-scheduler:dispatched',
|
|
20
|
+
display: true,
|
|
21
|
+
})
|
|
22
|
+
expect(backend.sentMessages[0]!.opts).toEqual({ deliverAs: 'followUp', triggerTurn: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('records persist calls and throws injected persistError', async () => {
|
|
26
|
+
const backend = new MockSchedulerBackend()
|
|
27
|
+
const store: SchedulerStore = { version: 1, tasks: [] }
|
|
28
|
+
|
|
29
|
+
await backend.persist(store)
|
|
30
|
+
expect(backend.persistedStores).toHaveLength(1)
|
|
31
|
+
expect(backend.persistedStores[0]).toBe(store)
|
|
32
|
+
|
|
33
|
+
// persistError 注入:persist 抛该错(ERR-6 语义——错误必须能传到调用栈)
|
|
34
|
+
backend.persistError = new Error('disk full')
|
|
35
|
+
await expect(backend.persist(store)).rejects.toThrow('disk full')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('now() returns injected nowValue or Date.now()', () => {
|
|
39
|
+
const backend = new MockSchedulerBackend()
|
|
40
|
+
expect(Math.abs(backend.now() - Date.now())).toBeLessThan(1000)
|
|
41
|
+
backend.nowValue = 123456
|
|
42
|
+
expect(backend.now()).toBe(123456)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ── TC2:new SchedulerRuntime(mockBackend) 可注入单测,零 FS ──
|
|
46
|
+
|
|
47
|
+
it('TC2: SchedulerRuntime with MockSchedulerBackend constructs and dispatches via mock', async () => {
|
|
48
|
+
const backend = new MockSchedulerBackend()
|
|
49
|
+
// 构造不抛错(无需 cwd/pi/store mock)
|
|
50
|
+
const runtime = new SchedulerRuntime(backend, mockCtx)
|
|
51
|
+
const task = await runtime.addTask('probe', { mode: 'interval', intervalMs: 60000 })
|
|
52
|
+
expect(task).toBeDefined()
|
|
53
|
+
// addTask 的 persist 走了 mock backend(零 FS)
|
|
54
|
+
expect(backend.persistedStores).toHaveLength(1)
|
|
55
|
+
|
|
56
|
+
await runtime.dispatchTask(task)
|
|
57
|
+
|
|
58
|
+
// dispatch 消息走 mock backend,含 task.prompt
|
|
59
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
60
|
+
expect(backend.sentMessages[0]!.msg.content).toBe('probe')
|
|
61
|
+
expect(backend.sentMessages[0]!.msg.customType).toBe('pi-scheduler:dispatched')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
3
4
|
import { executeScheduleCommand, registerScheduleCommand } from '../commands.js'
|
|
4
5
|
import { SchedulerRuntime } from '../runtime.js'
|
|
6
|
+
import { SchedulerService } from '../service.js'
|
|
5
7
|
|
|
6
|
-
//
|
|
7
|
-
vi.mock('../store.js', () => ({
|
|
8
|
-
createStore: () => ({
|
|
9
|
-
load: () => ({ version: 1, tasks: [] }),
|
|
10
|
-
persist: vi.fn(),
|
|
11
|
-
persistSync: vi.fn(),
|
|
12
|
-
storePath: '/mocked.json',
|
|
13
|
-
}),
|
|
14
|
-
}))
|
|
8
|
+
// MockSchedulerBackend 零 FS 副作用,无需 mock store.js。
|
|
15
9
|
|
|
16
10
|
interface CommandOpts {
|
|
17
11
|
description: string
|
|
@@ -20,7 +14,7 @@ interface CommandOpts {
|
|
|
20
14
|
}
|
|
21
15
|
|
|
22
16
|
describe('/schedule command', () => {
|
|
23
|
-
let
|
|
17
|
+
let service: SchedulerService
|
|
24
18
|
let commandOpts: CommandOpts
|
|
25
19
|
|
|
26
20
|
beforeEach(() => {
|
|
@@ -31,31 +25,29 @@ describe('/schedule command', () => {
|
|
|
31
25
|
commandOpts = opts
|
|
32
26
|
},
|
|
33
27
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{ sendMessage: vi.fn() } as never,
|
|
37
|
-
{ isIdle: () => true, hasPendingMessages: () => false } as never,
|
|
28
|
+
service = new SchedulerService(
|
|
29
|
+
new SchedulerRuntime(new MockSchedulerBackend(), { isIdle: () => true, hasPendingMessages: () => false }),
|
|
38
30
|
)
|
|
39
|
-
registerScheduleCommand(mockPi as never, () =>
|
|
31
|
+
registerScheduleCommand(mockPi as never, () => service)
|
|
40
32
|
})
|
|
41
33
|
|
|
42
34
|
// ── 子命令路由:list ──
|
|
43
35
|
|
|
44
36
|
it('list returns empty message when no tasks', async () => {
|
|
45
|
-
expect(await executeScheduleCommand(
|
|
37
|
+
expect(await executeScheduleCommand(service, 'list')).toBe('No scheduled tasks.')
|
|
46
38
|
})
|
|
47
39
|
|
|
48
40
|
it('list returns formatted task lines', async () => {
|
|
49
|
-
await
|
|
50
|
-
const result = await executeScheduleCommand(
|
|
41
|
+
await service.create('check build', '5m')
|
|
42
|
+
const result = await executeScheduleCommand(service, 'list')
|
|
51
43
|
expect(result).toContain('check build')
|
|
52
|
-
expect(result).toContain('every
|
|
44
|
+
expect(result).toContain('every 5m')
|
|
53
45
|
})
|
|
54
46
|
|
|
55
47
|
it('list marks disabled tasks with ○', async () => {
|
|
56
|
-
const
|
|
57
|
-
await
|
|
58
|
-
const result = await executeScheduleCommand(
|
|
48
|
+
const created = await service.create('paused task', '5m')
|
|
49
|
+
await service.toggle(created.data!.task.id, false)
|
|
50
|
+
const result = await executeScheduleCommand(service, 'list')
|
|
59
51
|
expect(result).toContain('○')
|
|
60
52
|
expect(result).toContain('paused task')
|
|
61
53
|
})
|
|
@@ -63,110 +55,114 @@ describe('/schedule command', () => {
|
|
|
63
55
|
// ── 子命令路由:on / off ──
|
|
64
56
|
|
|
65
57
|
it('off toggles task enabled to false', async () => {
|
|
66
|
-
const
|
|
67
|
-
const result = await executeScheduleCommand(
|
|
58
|
+
const created = await service.create('test', '5m')
|
|
59
|
+
const result = await executeScheduleCommand(service, `off ${created.data!.task.id}`)
|
|
68
60
|
expect(result).toContain('disabled')
|
|
69
|
-
expect(runtime.getTask(task.id)?.enabled).toBe(false)
|
|
61
|
+
expect(service.runtime.getTask(created.data!.task.id)?.enabled).toBe(false)
|
|
70
62
|
})
|
|
71
63
|
|
|
72
64
|
it('on toggles task enabled to true', async () => {
|
|
73
|
-
const
|
|
74
|
-
await
|
|
75
|
-
const result = await executeScheduleCommand(
|
|
65
|
+
const created = await service.create('test', '5m')
|
|
66
|
+
await service.toggle(created.data!.task.id, false)
|
|
67
|
+
const result = await executeScheduleCommand(service, `on ${created.data!.task.id}`)
|
|
76
68
|
expect(result).toContain('enabled')
|
|
77
|
-
expect(runtime.getTask(task.id)?.enabled).toBe(true)
|
|
69
|
+
expect(service.runtime.getTask(created.data!.task.id)?.enabled).toBe(true)
|
|
78
70
|
})
|
|
79
71
|
|
|
80
72
|
it('off with missing id returns usage', async () => {
|
|
81
|
-
expect(await executeScheduleCommand(
|
|
73
|
+
expect(await executeScheduleCommand(service, 'off')).toBe('Usage: /schedule off <id>')
|
|
82
74
|
})
|
|
83
75
|
|
|
84
76
|
it('on with missing id returns usage', async () => {
|
|
85
|
-
expect(await executeScheduleCommand(
|
|
77
|
+
expect(await executeScheduleCommand(service, 'on')).toBe('Usage: /schedule on <id>')
|
|
86
78
|
})
|
|
87
79
|
|
|
80
|
+
// TC5 command 侧:消息同源(service 产出 TASK_NOT_FOUND message)
|
|
88
81
|
it('off with unknown id returns not found', async () => {
|
|
89
|
-
expect(await executeScheduleCommand(
|
|
82
|
+
expect(await executeScheduleCommand(service, 'off deadbeef')).toBe('Task deadbeef not found.')
|
|
90
83
|
})
|
|
91
84
|
|
|
92
85
|
// ── 子命令路由:rm ──
|
|
93
86
|
|
|
94
87
|
it('rm deletes task', async () => {
|
|
95
|
-
const
|
|
96
|
-
const result = await executeScheduleCommand(
|
|
88
|
+
const created = await service.create('test', '5m')
|
|
89
|
+
const result = await executeScheduleCommand(service, `rm ${created.data!.task.id}`)
|
|
97
90
|
expect(result).toContain('deleted')
|
|
98
|
-
expect(runtime.getTask(task.id)).toBeUndefined()
|
|
91
|
+
expect(service.runtime.getTask(created.data!.task.id)).toBeUndefined()
|
|
99
92
|
})
|
|
100
93
|
|
|
101
94
|
it('rm with missing id returns usage', async () => {
|
|
102
|
-
expect(await executeScheduleCommand(
|
|
95
|
+
expect(await executeScheduleCommand(service, 'rm')).toBe('Usage: /schedule rm <id>')
|
|
103
96
|
})
|
|
104
97
|
|
|
105
98
|
it('rm with unknown id returns not found', async () => {
|
|
106
|
-
expect(await executeScheduleCommand(
|
|
99
|
+
expect(await executeScheduleCommand(service, 'rm deadbeef')).toBe('Task deadbeef not found.')
|
|
107
100
|
})
|
|
108
101
|
|
|
109
102
|
// ── 子命令路由:run ──
|
|
110
103
|
|
|
111
104
|
it('run executes task', async () => {
|
|
112
|
-
const
|
|
113
|
-
const result = await executeScheduleCommand(
|
|
105
|
+
const created = await service.create('test', '5m')
|
|
106
|
+
const result = await executeScheduleCommand(service, `run ${created.data!.task.id}`)
|
|
114
107
|
expect(result).toContain('executed')
|
|
115
108
|
// dispatchTask 更新 task 对象(同一引用),runCount 自增到 1。
|
|
116
|
-
expect(runtime.getTask(task.id)?.runCount).toBe(1)
|
|
109
|
+
expect(service.runtime.getTask(created.data!.task.id)?.runCount).toBe(1)
|
|
117
110
|
})
|
|
118
111
|
|
|
119
112
|
it('run with missing id returns usage', async () => {
|
|
120
|
-
expect(await executeScheduleCommand(
|
|
113
|
+
expect(await executeScheduleCommand(service, 'run')).toBe('Usage: /schedule run <id>')
|
|
121
114
|
})
|
|
122
115
|
|
|
123
116
|
it('run with unknown id returns not found', async () => {
|
|
124
|
-
expect(await executeScheduleCommand(
|
|
117
|
+
expect(await executeScheduleCommand(service, 'run deadbeef')).toBe('Task deadbeef not found.')
|
|
125
118
|
})
|
|
126
119
|
|
|
127
120
|
// ── 创建任务分支 ──
|
|
128
121
|
|
|
129
122
|
it('creates interval task from /schedule 5m check build', async () => {
|
|
130
|
-
const result = await executeScheduleCommand(
|
|
123
|
+
const result = await executeScheduleCommand(service, '5m check build')
|
|
131
124
|
expect(result).toContain('check build')
|
|
132
125
|
expect(result).toContain('every 5m')
|
|
133
|
-
expect(runtime.listTasks()).toHaveLength(1)
|
|
126
|
+
expect(service.runtime.listTasks()).toHaveLength(1)
|
|
134
127
|
})
|
|
135
128
|
|
|
136
129
|
it('created interval task is recurring by default', async () => {
|
|
137
|
-
await executeScheduleCommand(
|
|
138
|
-
const task = runtime.listTasks()[0]!
|
|
130
|
+
await executeScheduleCommand(service, '5m check build')
|
|
131
|
+
const task = service.runtime.listTasks()[0]!
|
|
139
132
|
expect(task.kind).toBe('recurring')
|
|
140
133
|
})
|
|
141
134
|
|
|
142
135
|
it('creates once task from /schedule once 10s remind', async () => {
|
|
143
|
-
const result = await executeScheduleCommand(
|
|
136
|
+
const result = await executeScheduleCommand(service, 'once 10s remind me')
|
|
144
137
|
expect(result).toContain('remind me')
|
|
138
|
+
// once 显示为 'once in 10s'(非误导性的 'every 10s')
|
|
139
|
+
expect(result).toContain('once in 10s')
|
|
140
|
+
expect(result).not.toContain('every 10s')
|
|
145
141
|
// once 任务 dispatch 后会被删除,但创建时尚未 dispatch
|
|
146
|
-
expect(runtime.listTasks()).toHaveLength(1)
|
|
147
|
-
const task = runtime.listTasks()[0]!
|
|
142
|
+
expect(service.runtime.listTasks()).toHaveLength(1)
|
|
143
|
+
const task = service.runtime.listTasks()[0]!
|
|
148
144
|
expect(task.kind).toBe('once')
|
|
149
145
|
})
|
|
150
146
|
|
|
151
147
|
// Quote-aware tokenizer 修复后,cron 'expr' 能正确提取整个表达式。
|
|
152
148
|
it('creates cron task from quoted expression', async () => {
|
|
153
|
-
const result = await executeScheduleCommand(
|
|
149
|
+
const result = await executeScheduleCommand(service, "cron '*/10 * * * *' prompt")
|
|
154
150
|
expect(result).toContain('created')
|
|
155
151
|
expect(result).toContain('*/10 * * * *')
|
|
156
|
-
expect(runtime.listTasks()).toHaveLength(1)
|
|
152
|
+
expect(service.runtime.listTasks()).toHaveLength(1)
|
|
157
153
|
})
|
|
158
154
|
|
|
159
155
|
it('creates cron task from double-quoted expression', async () => {
|
|
160
|
-
const result = await executeScheduleCommand(
|
|
156
|
+
const result = await executeScheduleCommand(service, 'cron "0 9 * * 1-5" standup reminder')
|
|
161
157
|
expect(result).toContain('created')
|
|
162
158
|
expect(result).toContain('0 9 * * 1-5')
|
|
163
|
-
expect(runtime.listTasks()).toHaveLength(1)
|
|
159
|
+
expect(service.runtime.listTasks()).toHaveLength(1)
|
|
164
160
|
})
|
|
165
161
|
|
|
166
162
|
// Unquoted multi-token cron still fails -- tokenizer cannot distinguish cron fields from prompt.
|
|
167
163
|
// Users should quote the cron expression or use the schedule tool (JSON params are unambiguous).
|
|
168
164
|
it('cron branch fails on unquoted multi-token expression (use quotes)', async () => {
|
|
169
|
-
const result = await executeScheduleCommand(
|
|
165
|
+
const result = await executeScheduleCommand(service, 'cron */10 * * * * prompt')
|
|
170
166
|
expect(result).toMatch(/^Invalid schedule:/)
|
|
171
167
|
expect(result).toContain('*/10')
|
|
172
168
|
})
|
|
@@ -174,21 +170,21 @@ describe('/schedule command', () => {
|
|
|
174
170
|
// ── 错误分支 ──
|
|
175
171
|
|
|
176
172
|
it('invalid schedule returns error message', async () => {
|
|
177
|
-
const result = await executeScheduleCommand(
|
|
173
|
+
const result = await executeScheduleCommand(service, 'invalid-duration-str')
|
|
178
174
|
expect(result).toMatch(/invalid|usage/i)
|
|
179
175
|
})
|
|
180
176
|
|
|
181
177
|
it('schedule with no prompt returns usage', async () => {
|
|
182
|
-
const result = await executeScheduleCommand(
|
|
178
|
+
const result = await executeScheduleCommand(service, '5m')
|
|
183
179
|
expect(result).toBe('Usage: /schedule <schedule> <prompt>')
|
|
184
180
|
})
|
|
185
181
|
|
|
186
182
|
it('no args returns TUI not-implemented message', async () => {
|
|
187
|
-
const result = await executeScheduleCommand(
|
|
183
|
+
const result = await executeScheduleCommand(service, '')
|
|
188
184
|
expect(result).toContain('not yet implemented')
|
|
189
185
|
})
|
|
190
186
|
|
|
191
|
-
it('returns error when
|
|
187
|
+
it('returns error when service is null', async () => {
|
|
192
188
|
expect(await executeScheduleCommand(null, 'list')).toBe('Scheduler not initialized: session not started.')
|
|
193
189
|
})
|
|
194
190
|
|
|
@@ -215,7 +211,8 @@ describe('/schedule command', () => {
|
|
|
215
211
|
})
|
|
216
212
|
|
|
217
213
|
it('completes task ids after on/off/rm/run', async () => {
|
|
218
|
-
const
|
|
214
|
+
const created = await service.create('mytask', '5m')
|
|
215
|
+
const task = created.data!.task
|
|
219
216
|
// 注意:路由要求 parts.length >= 2 才进 task-id 分支('on ' 单 token 进子命令分支)。
|
|
220
217
|
// 当前实现对部分输入的 id 不做过滤,返回所有 task id。
|
|
221
218
|
const completions = commandOpts.getArgumentCompletions(`on ${task.id.slice(0, 2)}`) as Array<{ label: string; description: string }>
|
|
@@ -224,7 +221,7 @@ describe('/schedule command', () => {
|
|
|
224
221
|
expect(completions.find(c => c.label === task.id)?.description).toContain('mytask')
|
|
225
222
|
})
|
|
226
223
|
|
|
227
|
-
it('returns null for completion when
|
|
224
|
+
it('returns null for completion when service missing and prefix has 2 tokens', () => {
|
|
228
225
|
const mockPi = {
|
|
229
226
|
registerCommand: (_name: string, opts: CommandOpts) => {
|
|
230
227
|
commandOpts = opts
|
|
@@ -58,11 +58,10 @@ describe('computeNextCronRuns', () => {
|
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
describe('parseSchedule (cron 分支)', () => {
|
|
61
|
-
it('5 字段 cron
|
|
61
|
+
it('5 字段 cron 表达式补秒字段(note 不再透传到 parseSchedule 结果)', async () => {
|
|
62
62
|
const result = await parseSchedule('*/10 * * * *')
|
|
63
63
|
expect(result).toEqual({
|
|
64
64
|
spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
|
|
65
|
-
note: 'Auto-prepended seconds field (0)',
|
|
66
65
|
})
|
|
67
66
|
})
|
|
68
67
|
|
|
@@ -9,6 +9,13 @@ describe('formatSchedule', () => {
|
|
|
9
9
|
expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 })).toBe('every 1h')
|
|
10
10
|
})
|
|
11
11
|
|
|
12
|
+
it('formats once kind as "once in X" (not misleading "every X")', () => {
|
|
13
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'once')).toBe('once in 5m')
|
|
14
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 3_600_000 }, 'once')).toBe('once in 1h')
|
|
15
|
+
// recurring/缺省保持 every
|
|
16
|
+
expect(formatSchedule({ mode: 'interval', intervalMs: 300_000 }, 'recurring')).toBe('every 5m')
|
|
17
|
+
})
|
|
18
|
+
|
|
12
19
|
it('formats cron spec', () => {
|
|
13
20
|
expect(formatSchedule({ mode: 'cron', cronExpression: '*/10 * * * *' })).toBe('*/10 * * * *')
|
|
14
21
|
})
|
|
@@ -83,6 +90,34 @@ describe('formatDuration', () => {
|
|
|
83
90
|
expect(formatDuration(90_000)).toBe('90s')
|
|
84
91
|
expect(formatDuration(1500)).toBe('2s')
|
|
85
92
|
})
|
|
93
|
+
|
|
94
|
+
// TC7 边界值:最大单位整除优先(300000 → '5m' 而非 '300s')
|
|
95
|
+
it('prefers largest unit when ms is evenly divisible', () => {
|
|
96
|
+
expect(formatDuration(300_000)).toBe('5m')
|
|
97
|
+
expect(formatDuration(3_600_000)).toBe('1h')
|
|
98
|
+
expect(formatDuration(86_400_000)).toBe('1d')
|
|
99
|
+
expect(formatDuration(60_000)).toBe('1m')
|
|
100
|
+
expect(formatDuration(1000)).toBe('1s')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('handles zero and negative as 0s', () => {
|
|
104
|
+
expect(formatDuration(0)).toBe('0s')
|
|
105
|
+
expect(formatDuration(-1)).toBe('0s')
|
|
106
|
+
expect(formatDuration(-86_400_000)).toBe('0s')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// TC7 修正(design-review R1):1500000 % 60000 === 0 → '25m'(最大单位优先),
|
|
110
|
+
// 而非 design 初稿误写的 '1500s'。非整除大单位落秒用例改用 1510000。
|
|
111
|
+
it('prefers even minute when ms is multiple of 60000 (1500000 → 25m)', () => {
|
|
112
|
+
expect(formatDuration(1_500_000)).toBe('25m')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('falls back to seconds for large non-divisible values (1510000 → 1510s)', () => {
|
|
116
|
+
// 1510000 % 60000 = 10000 ≠ 0 → 不整除 m,落秒分支
|
|
117
|
+
expect(formatDuration(1_510_000)).toBe('1510s')
|
|
118
|
+
// 1500001 % 1000 ≠ 0 → Math.round 兜底
|
|
119
|
+
expect(formatDuration(1_500_001)).toBe('1500s')
|
|
120
|
+
})
|
|
86
121
|
})
|
|
87
122
|
|
|
88
123
|
describe('generateTaskId', () => {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
computeNextCronRunAt,
|
|
5
|
+
computeNextCronRuns,
|
|
6
|
+
computeNextRunAt,
|
|
4
7
|
computeNextRuns,
|
|
5
8
|
formatDuration,
|
|
6
9
|
normalizeCronExpression,
|
|
@@ -130,6 +133,32 @@ describe('parseSchedule', () => {
|
|
|
130
133
|
})
|
|
131
134
|
})
|
|
132
135
|
|
|
136
|
+
describe('computeNextRunAt', () => {
|
|
137
|
+
it('interval 模式 → from + intervalMs(精确相等)', async () => {
|
|
138
|
+
const from = Date.now()
|
|
139
|
+
const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 }, from)
|
|
140
|
+
expect(next).toBe(from + 60_000)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('interval 模式 from 缺省用当前时间', async () => {
|
|
144
|
+
const before = Date.now()
|
|
145
|
+
const next = await computeNextRunAt({ mode: 'interval', intervalMs: 60_000 })
|
|
146
|
+
expect(next!).toBeGreaterThanOrEqual(before + 60_000)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('cron 有效 → 返回未来时间戳(> from)', async () => {
|
|
150
|
+
const from = Date.now()
|
|
151
|
+
const next = await computeNextRunAt({ mode: 'cron', cronExpression: '*/10 * * * *' }, from)
|
|
152
|
+
expect(next).not.toBeUndefined()
|
|
153
|
+
expect(next!).toBeGreaterThan(from)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('cron 无效 → undefined', async () => {
|
|
157
|
+
const next = await computeNextRunAt({ mode: 'cron', cronExpression: 'invalid * *' }, Date.now())
|
|
158
|
+
expect(next).toBeUndefined()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
133
162
|
describe('computeNextRuns', () => {
|
|
134
163
|
it('computes interval runs', async () => {
|
|
135
164
|
const from = Date.now()
|