@zhushanwen/pi-scheduler 0.0.2

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.
@@ -0,0 +1,63 @@
1
+ import { describe, expect,it } from 'vitest'
2
+
3
+ import type { ScheduledTask } from '../types.js'
4
+ import { renderSchedulerWidget } from '../widget.js'
5
+
6
+ const makeTask = (overrides: Partial<ScheduledTask> = {}): ScheduledTask => ({
7
+ id: 'abc12345',
8
+ name: 'test task',
9
+ prompt: 'test prompt',
10
+ kind: 'recurring',
11
+ schedule: { mode: 'interval', intervalMs: 60000 },
12
+ enabled: true,
13
+ force: false,
14
+ createdAt: Date.now(),
15
+ nextRunAt: Date.now() + 60000,
16
+ runCount: 0,
17
+ history: [],
18
+ ...overrides,
19
+ })
20
+
21
+ describe('renderSchedulerWidget', () => {
22
+ it('returns empty array when no tasks', () => {
23
+ expect(renderSchedulerWidget([])).toEqual([])
24
+ })
25
+
26
+ it('renders task count', () => {
27
+ const tasks = [makeTask(), makeTask({ id: 'def67890', name: 'another' })]
28
+ const result = renderSchedulerWidget(tasks)
29
+ expect(result[0]).toContain('2 scheduled')
30
+ })
31
+
32
+ it('renders next upcoming task', () => {
33
+ const tasks = [makeTask({ name: 'check build' })]
34
+ const result = renderSchedulerWidget(tasks)
35
+ expect(result[0]).toContain('check build')
36
+ expect(result[0]).toContain('in')
37
+ })
38
+
39
+ it('renders overdue count', () => {
40
+ const tasks = [makeTask({ nextRunAt: Date.now() - 1000 })]
41
+ const result = renderSchedulerWidget(tasks)
42
+ expect(result[0]).toContain('1 overdue')
43
+ })
44
+
45
+ it('starts with [scheduler] prefix', () => {
46
+ const tasks = [makeTask()]
47
+ const result = renderSchedulerWidget(tasks)
48
+ expect(result[0]).toMatch(/^\[scheduler\]/)
49
+ })
50
+
51
+ // disabled 任务被过滤:scheduled 计数与 overdue/upcoming 都只统计 enabled
52
+ it('excludes disabled tasks from counts', () => {
53
+ const tasks = [
54
+ makeTask({ id: 'enabled1', name: 'active', enabled: true }),
55
+ makeTask({ id: 'disabled1', name: 'inactive', enabled: false, nextRunAt: Date.now() - 1000 }),
56
+ ]
57
+ const result = renderSchedulerWidget(tasks)
58
+ expect(result[0]).toContain('1 scheduled')
59
+ expect(result[0]).not.toContain('1 overdue')
60
+ expect(result[0]).toContain('active')
61
+ expect(result[0]).not.toContain('inactive')
62
+ })
63
+ })
@@ -0,0 +1,150 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
2
+
3
+ import { formatRelativeTime, formatSchedule } from './format.js'
4
+ import { parseSchedule } from './parsing.js'
5
+ import type { SchedulerRuntime } from './runtime.js'
6
+
7
+ /**
8
+ * Shell-style quote-aware tokenizer.
9
+ * Supports single/double quoted tokens (e.g. cron expressions with spaces).
10
+ * Quoted content is kept as a single token; quote chars are stripped from output.
11
+ */
12
+ function tokenizeQuoted(input: string): string[] {
13
+ const tokens: string[] = []
14
+ let current = ''
15
+ let inQuote: '"' | "'" | null = null
16
+
17
+ for (let i = 0; i < input.length; i++) {
18
+ const ch = input[i]!
19
+ if (inQuote) {
20
+ if (ch === inQuote) {
21
+ inQuote = null
22
+ } else {
23
+ current += ch
24
+ }
25
+ } else if (ch === '"' || ch === "'") {
26
+ inQuote = ch
27
+ } else if (ch === ' ' || ch === '\t') {
28
+ if (current) {
29
+ tokens.push(current)
30
+ current = ''
31
+ }
32
+ } else {
33
+ current += ch
34
+ }
35
+ }
36
+ if (current) tokens.push(current)
37
+ return tokens
38
+ }
39
+
40
+ /**
41
+ * 注册 /schedule command。
42
+ * 消歧规则:第一个参数匹配子命令关键词则走对应分支,否则尝试 parseSchedule 创建任务。
43
+ *
44
+ * runtime 通过 getter 获取:registerScheduleCommand 在 factory 顶层调用,此时 session_start
45
+ * 尚未触发、runtime 还是 null。getArgumentCompletions / handler 真正执行时才读 runtime 当前值。
46
+ */
47
+ export function registerScheduleCommand(
48
+ pi: ExtensionAPI,
49
+ getRuntime: () => SchedulerRuntime | null,
50
+ ) {
51
+ pi.registerCommand('schedule', {
52
+ description: 'Manage scheduled tasks. No args opens TUI. /schedule <schedule> <prompt> to create.',
53
+ getArgumentCompletions(prefix: string) {
54
+ const runtime = getRuntime()
55
+ const trimmed = prefix.trimStart()
56
+ const parts = trimmed.split(/\s+/).filter(Boolean)
57
+ if (parts.length <= 1) {
58
+ return [
59
+ { label: 'list', value: 'list', description: 'Show all scheduled tasks' },
60
+ { label: 'on', value: 'on ', description: 'Enable a task' },
61
+ { label: 'off', value: 'off ', description: 'Disable a task' },
62
+ { label: 'rm', value: 'rm ', description: 'Delete a task' },
63
+ { label: 'run', value: 'run ', description: 'Run a task now' },
64
+ { label: 'once', value: 'once ', description: 'Create a one-time reminder' },
65
+ { label: 'cron', value: "cron '", description: 'Create a cron-based task' },
66
+ ].filter(opt => opt.label.startsWith(trimmed.toLowerCase()))
67
+ }
68
+ // on/off/rm/run 后补全任务 id
69
+ if (['on', 'off', 'rm', 'run'].includes(parts[0]!) && runtime) {
70
+ return runtime.listTasks().map(t => ({
71
+ label: t.id,
72
+ value: t.id,
73
+ description: `${t.name} · ${formatSchedule(t.schedule)}`
74
+ }))
75
+ }
76
+ return null
77
+ },
78
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
79
+ const result = await executeScheduleCommand(getRuntime(), args)
80
+ ctx.ui.notify(result, 'info')
81
+ },
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Core logic for /schedule command. Extracted for testability (handler returns
87
+ * void per SDK contract; tests call this function directly to assert output).
88
+ */
89
+ export async function executeScheduleCommand(
90
+ runtime: SchedulerRuntime | null,
91
+ args: string,
92
+ ): Promise<string> {
93
+ if (!runtime) return 'Scheduler not initialized: session not started.'
94
+
95
+ const trimmed = args.trim()
96
+ if (!trimmed) {
97
+ // TODO: 打开 TUI 管理器(W5 实现)
98
+ return 'TUI manager not yet implemented. Use /schedule list to see tasks.'
99
+ }
100
+
101
+ const parts = tokenizeQuoted(trimmed)
102
+ const first = parts[0]!.toLowerCase()
103
+
104
+ // 子命令路由
105
+ if (first === 'list') {
106
+ const tasks = runtime.listTasks()
107
+ if (tasks.length === 0) return 'No scheduled tasks.'
108
+ return tasks.map(t =>
109
+ `${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule)} · ${formatRelativeTime(t.nextRunAt)}`
110
+ ).join('\n')
111
+ }
112
+
113
+ if (first === 'on' || first === 'off') {
114
+ const id = parts[1]
115
+ if (!id) return `Usage: /schedule ${first} <id>`
116
+ const success = await runtime.toggleTask(id, first === 'on')
117
+ return success ? `Task ${id} ${first === 'on' ? 'enabled' : 'disabled'}.` : `Task ${id} not found.`
118
+ }
119
+
120
+ if (first === 'rm') {
121
+ const id = parts[1]
122
+ if (!id) return 'Usage: /schedule rm <id>'
123
+ const success = runtime.deleteTask(id)
124
+ return success ? `Task ${id} deleted.` : `Task ${id} not found.`
125
+ }
126
+
127
+ if (first === 'run') {
128
+ const id = parts[1]
129
+ if (!id) return 'Usage: /schedule run <id>'
130
+ const success = await runtime.runTaskNow(id)
131
+ return success ? `Task ${id} executed.` : `Task ${id} not found.`
132
+ }
133
+
134
+ // 创建任务分支
135
+ const kind = first === 'once' ? 'once' as const : first === 'cron' ? 'recurring' as const : undefined
136
+ const scheduleStart = kind ? 1 : 0
137
+ const scheduleInput = parts[scheduleStart]
138
+ if (!scheduleInput) return 'Usage: /schedule <schedule> <prompt>'
139
+
140
+ const prompt = parts.slice(scheduleStart + 1).join(' ')
141
+ if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
142
+
143
+ const parsed = await parseSchedule(scheduleInput)
144
+ if (!parsed) {
145
+ return `Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression.`
146
+ }
147
+
148
+ const task = await runtime.addTask(prompt, parsed.spec, { kind })
149
+ return `Task "${task.name}" (${task.id}) created. Schedule: ${formatSchedule(task.schedule)}`
150
+ }
package/src/format.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { formatDuration } from './parsing.js'
2
+ import type { ScheduleSpec } from './types.js'
3
+
4
+ /** Format ScheduleSpec to readable string */
5
+ export function formatSchedule(spec: ScheduleSpec): string {
6
+ if (spec.mode === 'interval') {
7
+ return `every ${formatDuration(spec.intervalMs)}`
8
+ }
9
+ return spec.cronExpression
10
+ }
11
+
12
+ /**
13
+ * 格式化时间戳为相对时间字符串。
14
+ * 未来: "in 5m"
15
+ * 过去: "5m ago"
16
+ * 当前(+-5s): "now"
17
+ *
18
+ * now 可选参数:基准时间戳,默认 Date.now()。测试可传固定值快进/锁定,
19
+ * 生产调用方无需传(参数可选,行为不变)。
20
+ */
21
+ export function formatRelativeTime(timestamp: number, now?: number): string {
22
+ const currentTime = now ?? Date.now()
23
+ const diff = timestamp - currentTime
24
+
25
+ // 5秒内视为"现在"
26
+ if (Math.abs(diff) < 5000) return 'now'
27
+
28
+ const absDiff = Math.abs(diff)
29
+ const units: [string, number][] = [
30
+ ['d', 86_400_000],
31
+ ['h', 3_600_000],
32
+ ['m', 60_000],
33
+ ['s', 1000],
34
+ ]
35
+
36
+ let formatted = ''
37
+ for (const [suffix, divisor] of units) {
38
+ if (absDiff >= divisor) {
39
+ const value = Math.floor(absDiff / divisor)
40
+ formatted = `${value}${suffix}`
41
+ break
42
+ }
43
+ }
44
+
45
+ if (!formatted) {
46
+ formatted = `${Math.round(absDiff / 1000)}s`
47
+ }
48
+
49
+ return diff > 0 ? `in ${formatted}` : `${formatted} ago`
50
+ }
51
+
52
+ /**
53
+ * 截断文本到指定长度,超出部分用 "..." 替代。
54
+ */
55
+ export function truncate(text: string, maxLen: number): string {
56
+ if (text.length <= maxLen) return text
57
+ if (maxLen <= 3) return text.slice(0, maxLen)
58
+ return text.slice(0, maxLen - 3) + '...'
59
+ }
60
+
61
+ /**
62
+ * 生成任务 ID:8 位 hex。
63
+ */
64
+ export function generateTaskId(): string {
65
+ const bytes = new Uint8Array(4)
66
+ crypto.getRandomValues(bytes)
67
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
68
+ }
69
+
70
+ /**
71
+ * 从 prompt 自动生成任务名称:取前 30 字。
72
+ */
73
+ export function autoName(prompt: string): string {
74
+ const trimmed = prompt.trim()
75
+ if (trimmed.length <= 30) return trimmed
76
+ return trimmed.slice(0, 27) + '...'
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+
3
+ import { registerScheduleCommand } from './commands.js'
4
+ import { SchedulerRuntime } from './runtime.js'
5
+ import {
6
+ controlGuidelines,
7
+ createScheduleControlHandler,
8
+ createScheduleHandler,
9
+ ScheduleControlParams,
10
+ type ScheduleControlParamsT,
11
+ scheduleGuidelines,
12
+ ScheduleParams,
13
+ type ScheduleParamsT,
14
+ } from './tool.js'
15
+ import { renderSchedulerWidget } from './widget.js'
16
+
17
+ /** widget 刷新节奏:对齐 runtime 的 TICK_INTERVAL_MS(30s),保证 nextRunAt 倒计时随 tick 更新。 */
18
+ const WIDGET_REFRESH_MS = 30_000
19
+
20
+ /**
21
+ * pi-scheduler extension factory。
22
+ * 注册 schedule + schedule_control 两个 tool、/schedule command、session 事件。
23
+ *
24
+ * runtime 生命周期:在 session_start 中创建(依赖 ctx),factory 顶层只声明为 null。
25
+ * tool/command 的 execute/handler 通过 getRuntime() 延迟读取,避免在 factory 顶层
26
+ * 捕获 null——那时 session_start 尚未触发,runtime! 非空断言会骗过编译器但运行时是 null。
27
+ */
28
+ export default function schedulerExtension(pi: ExtensionAPI): void {
29
+ let runtime: SchedulerRuntime | null = null
30
+ // widget 刷新计时器:与 runtime 同生命周期,session_shutdown 时清理。
31
+ let widgetTimer: ReturnType<typeof setInterval> | null = null
32
+
33
+ const getRuntime = (): SchedulerRuntime => {
34
+ if (!runtime) throw new Error('Scheduler not initialized: session not started')
35
+ return runtime
36
+ }
37
+
38
+ pi.on('session_start', (_event, ctx: ExtensionContext) => {
39
+ runtime = new SchedulerRuntime(ctx.cwd, pi, ctx)
40
+ runtime.loadTasks()
41
+ runtime.startScheduler()
42
+
43
+ // 注册 widget(SDK setWidget 第一重载:直接传 string[])。
44
+ // string[] 只渲染一次,调度器需要随 task 状态/nextRunAt 倒计时刷新,
45
+ // 因此用一个对齐 TICK_INTERVAL_MS 的 interval 周期性重发 string[],
46
+ // 等价于 plan 扩展的 push 模式(plan/src/widget.ts:13)。
47
+ refreshWidget(ctx)
48
+ widgetTimer = setInterval(() => refreshWidget(ctx), WIDGET_REFRESH_MS)
49
+ })
50
+
51
+ pi.on('session_shutdown', () => {
52
+ if (widgetTimer) {
53
+ clearInterval(widgetTimer)
54
+ widgetTimer = null
55
+ }
56
+ if (runtime) {
57
+ runtime.persistSync()
58
+ runtime.stopScheduler()
59
+ }
60
+ })
61
+
62
+ // 注册 schedule tool
63
+ // execute 内联闭包:从 SDK 全签名 (toolCallId, params, signal, onUpdate, ctx) 提取 params 转调
64
+ // handler,并 catch 业务层抛出的错误转为 { isError: true }(standards.md §4.2 禁止抛)。
65
+ pi.registerTool({
66
+ name: 'schedule',
67
+ label: 'Schedule',
68
+ description: 'Create a scheduled task that fires a message at intervals or cron schedule.',
69
+ parameters: ScheduleParams,
70
+ promptGuidelines: scheduleGuidelines,
71
+ async execute(
72
+ _toolCallId: string,
73
+ params: ScheduleParamsT,
74
+ _signal: AbortSignal | undefined,
75
+ _onUpdate,
76
+ _ctx: ExtensionContext,
77
+ ) {
78
+ try {
79
+ return await createScheduleHandler(getRuntime())(params)
80
+ } catch (err) {
81
+ return {
82
+ content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
83
+ details: {},
84
+ isError: true,
85
+ }
86
+ }
87
+ },
88
+ })
89
+
90
+ // 注册 schedule_control tool
91
+ pi.registerTool({
92
+ name: 'schedule_control',
93
+ label: 'Schedule Control',
94
+ description: 'Manage scheduled tasks: list, toggle, delete, or run immediately.',
95
+ parameters: ScheduleControlParams,
96
+ promptGuidelines: controlGuidelines,
97
+ async execute(
98
+ _toolCallId: string,
99
+ params: ScheduleControlParamsT,
100
+ _signal: AbortSignal | undefined,
101
+ _onUpdate,
102
+ _ctx: ExtensionContext,
103
+ ) {
104
+ try {
105
+ return await createScheduleControlHandler(getRuntime())(params)
106
+ } catch (err) {
107
+ return {
108
+ content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
109
+ details: {},
110
+ isError: true,
111
+ }
112
+ }
113
+ },
114
+ })
115
+
116
+ // 注册 /schedule command。传 getter 而非 runtime 实例:factory 执行时 runtime 还是 null。
117
+ registerScheduleCommand(pi, () => runtime)
118
+
119
+ /**
120
+ * 重新计算并推送 scheduler widget(string[] 重载)。
121
+ * 读外层 runtime 变量而非 getRuntime():session_start 尚未触发时刷新不应报错,直接跳过。
122
+ */
123
+ function refreshWidget(ctx: ExtensionContext): void {
124
+ if (!runtime) return
125
+ ctx.ui.setWidget('scheduler', renderSchedulerWidget(runtime.listTasks()))
126
+ }
127
+ }
package/src/parsing.ts ADDED
@@ -0,0 +1,208 @@
1
+ import type { ParseScheduleResult, ScheduleSpec } from './types.js'
2
+
3
+ // ── Duration 解析 ──
4
+
5
+ const DURATION_RE = /^(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i
6
+
7
+ const DURATION_MULTIPLIERS: Record<string, number> = {
8
+ s: 1000, sec: 1000, second: 1000, seconds: 1000,
9
+ m: 60_000, min: 60_000, minute: 60_000, minutes: 60_000,
10
+ h: 3_600_000, hr: 3_600_000, hour: 3_600_000, hours: 3_600_000,
11
+ d: 86_400_000, day: 86_400_000, days: 86_400_000,
12
+ }
13
+
14
+ /**
15
+ * 解析 duration 字符串为毫秒数。
16
+ * 支持:5s, 5m, 2h, 1d, 30seconds, 2hours 等
17
+ * 返回 undefined 表示无法解析。
18
+ */
19
+ export function parseDuration(text: string): number | undefined {
20
+ const match = DURATION_RE.exec(text.trim())
21
+ if (!match) return undefined
22
+ const value = parseInt(match[1]!, 10)
23
+ const unit = match[2]!.toLowerCase()
24
+ const multiplier = DURATION_MULTIPLIERS[unit]
25
+ if (multiplier === undefined) return undefined
26
+ return value * multiplier
27
+ }
28
+
29
+ /**
30
+ * 格式化毫秒数为可读 duration 字符串。
31
+ * 优先使用最大单位:300000 → "5m",不是 "300s"
32
+ */
33
+ export function formatDuration(ms: number): string {
34
+ if (ms <= 0) return '0s'
35
+
36
+ const units: [string, number][] = [
37
+ ['d', 86_400_000],
38
+ ['h', 3_600_000],
39
+ ['m', 60_000],
40
+ ['s', 1000],
41
+ ]
42
+
43
+ for (const [suffix, divisor] of units) {
44
+ if (ms >= divisor && ms % divisor === 0) {
45
+ return `${ms / divisor}${suffix}`
46
+ }
47
+ }
48
+
49
+ // 兜底:用秒表示
50
+ return `${Math.round(ms / 1000)}s`
51
+ }
52
+
53
+ // ── Cron 解析 ──
54
+
55
+ let cronerModule: typeof import('croner') | null | undefined
56
+
57
+ async function getCroner(): Promise<typeof import('croner') | null> {
58
+ if (cronerModule !== undefined) return cronerModule
59
+ try {
60
+ cronerModule = await import('croner')
61
+ return cronerModule
62
+ } catch {
63
+ cronerModule = null
64
+ return null
65
+ }
66
+ }
67
+
68
+ /**
69
+ * 规范化 cron 表达式:5 字段自动补秒字段。
70
+ * 返回 undefined 表示无效。
71
+ */
72
+ export function normalizeCronExpression(input: string): { expression: string; note?: string } | undefined {
73
+ const trimmed = input.trim()
74
+ if (!trimmed) return undefined
75
+
76
+ const parts = trimmed.split(/\s+/)
77
+
78
+ // 6 字段原样返回
79
+ if (parts.length === 6) {
80
+ return { expression: trimmed }
81
+ }
82
+
83
+ // 5 字段补秒字段
84
+ if (parts.length === 5) {
85
+ return {
86
+ expression: `0 ${trimmed}`,
87
+ note: 'Auto-prepended seconds field (0)',
88
+ }
89
+ }
90
+
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * 计算 cron 表达式的下次执行时间。
96
+ * 返回 undefined 表示表达式无效或 croner 不可用。
97
+ */
98
+ export async function computeNextCronRunAt(
99
+ expression: string,
100
+ from?: number,
101
+ ): Promise<number | undefined> {
102
+ const croner = await getCroner()
103
+ if (!croner) return undefined
104
+
105
+ try {
106
+ const normalized = normalizeCronExpression(expression)
107
+ if (!normalized) return undefined
108
+
109
+ const job = new croner.Cron(normalized.expression, { startAt: from ? new Date(from) : undefined })
110
+ const next = job.nextRun()
111
+ return next ? next.getTime() : undefined
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 计算 cron 表达式的多个未来执行时间。
119
+ * count 默认 5。
120
+ */
121
+ export async function computeNextCronRuns(
122
+ expression: string,
123
+ from?: number,
124
+ count = 5,
125
+ ): Promise<number[]> {
126
+ const croner = await getCroner()
127
+ if (!croner) return []
128
+
129
+ try {
130
+ const normalized = normalizeCronExpression(expression)
131
+ if (!normalized) return []
132
+
133
+ const job = new croner.Cron(normalized.expression, { startAt: from ? new Date(from) : undefined })
134
+ const runs: number[] = []
135
+ let current = from ? new Date(from) : new Date()
136
+
137
+ for (let i = 0; i < count; i++) {
138
+ const next = job.nextRun(current)
139
+ if (!next) break
140
+ runs.push(next.getTime())
141
+ current = next
142
+ }
143
+
144
+ return runs
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ // ── 统一解析 ──
151
+
152
+ /**
153
+ * 统一解析 schedule 输入。
154
+ * 不含空格 → duration 解析 → interval mode
155
+ * 含空格 → cron 解析 → cron mode
156
+ * 都失败 → undefined
157
+ */
158
+ export async function parseSchedule(
159
+ input: string,
160
+ ): Promise<ParseScheduleResult | undefined> {
161
+ const trimmed = input.trim()
162
+ if (!trimmed) return undefined
163
+
164
+ // 不含空格 → 尝试 duration
165
+ if (!trimmed.includes(' ')) {
166
+ const ms = parseDuration(trimmed)
167
+ if (ms !== undefined) {
168
+ return {
169
+ spec: { mode: 'interval', intervalMs: ms },
170
+ }
171
+ }
172
+ return undefined
173
+ }
174
+
175
+ // 含空格 → 尝试 cron
176
+ const normalized = normalizeCronExpression(trimmed)
177
+ if (normalized) {
178
+ // 验证 cron 表达式有效
179
+ const nextRun = await computeNextCronRunAt(trimmed)
180
+ if (nextRun !== undefined) {
181
+ return {
182
+ spec: { mode: 'cron', cronExpression: normalized.expression },
183
+ note: normalized.note,
184
+ }
185
+ }
186
+ }
187
+
188
+ return undefined
189
+ }
190
+
191
+ // ── Next Runs 计算 ──
192
+
193
+ /**
194
+ * 统一计算多个未来执行时间。
195
+ * interval 模式直接乘法,cron 模式调用 croner。
196
+ */
197
+ export async function computeNextRuns(
198
+ spec: ScheduleSpec,
199
+ from?: number,
200
+ count = 5,
201
+ ): Promise<number[]> {
202
+ if (spec.mode === 'interval') {
203
+ const start = from ?? Date.now()
204
+ return Array.from({ length: count }, (_, i) => start + spec.intervalMs * (i + 1))
205
+ }
206
+
207
+ return computeNextCronRuns(spec.cronExpression, from, count)
208
+ }