@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.
- package/index.ts +1 -0
- package/package.json +43 -0
- package/src/__tests__/commands.test.ts +237 -0
- package/src/__tests__/cron.test.ts +107 -0
- package/src/__tests__/format.test.ts +115 -0
- package/src/__tests__/parsing.test.ts +162 -0
- package/src/__tests__/runtime.test.ts +232 -0
- package/src/__tests__/sdk-contract.test.ts +178 -0
- package/src/__tests__/store.test.ts +161 -0
- package/src/__tests__/tool.test.ts +74 -0
- package/src/__tests__/widget.test.ts +63 -0
- package/src/commands.ts +150 -0
- package/src/format.ts +77 -0
- package/src/index.ts +127 -0
- package/src/parsing.ts +208 -0
- package/src/runtime.ts +253 -0
- package/src/store.ts +117 -0
- package/src/tool.ts +131 -0
- package/src/types.ts +59 -0
- package/src/widget.ts +34 -0
- package/vitest.config.ts +7 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import { autoName, generateTaskId } from './format.js'
|
|
4
|
+
import { computeNextCronRunAt, parseDuration } from './parsing.js'
|
|
5
|
+
import { createStore } from './store.js'
|
|
6
|
+
import type { AddOptions, ScheduledTask, SchedulerStore, ScheduleSpec } from './types.js'
|
|
7
|
+
|
|
8
|
+
const MAX_TASKS = 50
|
|
9
|
+
const RATE_LIMIT_PER_MINUTE = 6
|
|
10
|
+
const TICK_INTERVAL_MS = 30_000
|
|
11
|
+
const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
12
|
+
|
|
13
|
+
export class SchedulerRuntime {
|
|
14
|
+
private tasks: Map<string, ScheduledTask> = new Map()
|
|
15
|
+
private store: ReturnType<typeof createStore>
|
|
16
|
+
private pi: Pick<ExtensionAPI, 'sendMessage'>
|
|
17
|
+
private ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>
|
|
18
|
+
private tickTimer: ReturnType<typeof setInterval> | null = null
|
|
19
|
+
private dispatchTimestamps: number[] = []
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
cwd: string,
|
|
23
|
+
pi: Pick<ExtensionAPI, 'sendMessage'>,
|
|
24
|
+
ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
25
|
+
) {
|
|
26
|
+
this.store = createStore(cwd)
|
|
27
|
+
this.pi = pi
|
|
28
|
+
this.ctx = ctx
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── 任务 CRUD ──
|
|
32
|
+
|
|
33
|
+
async addTask(prompt: string, schedule: ScheduleSpec, options: AddOptions = {}): Promise<ScheduledTask> {
|
|
34
|
+
if (this.tasks.size >= MAX_TASKS) {
|
|
35
|
+
throw new Error(`Task limit reached (${MAX_TASKS}). Delete a task first.`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const id = generateTaskId()
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
const kind = options.kind ?? 'recurring'
|
|
41
|
+
const name = options.name ?? autoName(prompt)
|
|
42
|
+
|
|
43
|
+
let expiresAt: number | undefined
|
|
44
|
+
if (options.expires === 'never') {
|
|
45
|
+
expiresAt = undefined
|
|
46
|
+
} else if (kind === 'recurring') {
|
|
47
|
+
const expiryMs = options.expires ? (parseDuration(options.expires) ?? DEFAULT_EXPIRY_MS) : DEFAULT_EXPIRY_MS
|
|
48
|
+
expiresAt = now + expiryMs
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 计算 nextRunAt:interval 模式 now + intervalMs;cron 模式首跑时间
|
|
52
|
+
let nextRunAt: number
|
|
53
|
+
if (schedule.mode === 'interval') {
|
|
54
|
+
nextRunAt = now + schedule.intervalMs
|
|
55
|
+
} else {
|
|
56
|
+
const next = await computeNextCronRunAt(schedule.cronExpression, now)
|
|
57
|
+
if (next === undefined) {
|
|
58
|
+
throw new Error(`Invalid cron expression: ${schedule.cronExpression}`)
|
|
59
|
+
}
|
|
60
|
+
nextRunAt = next
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const task: ScheduledTask = {
|
|
64
|
+
id,
|
|
65
|
+
name,
|
|
66
|
+
prompt,
|
|
67
|
+
kind,
|
|
68
|
+
schedule,
|
|
69
|
+
enabled: true,
|
|
70
|
+
force: options.force ?? false,
|
|
71
|
+
createdAt: now,
|
|
72
|
+
nextRunAt,
|
|
73
|
+
expiresAt,
|
|
74
|
+
runCount: 0,
|
|
75
|
+
history: [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.tasks.set(id, task)
|
|
79
|
+
this.persist()
|
|
80
|
+
return task
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
listTasks(): ScheduledTask[] {
|
|
84
|
+
return Array.from(this.tasks.values()).sort((a, b) => a.nextRunAt - b.nextRunAt)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getTask(id: string): ScheduledTask | undefined {
|
|
88
|
+
return this.tasks.get(id)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async toggleTask(id: string, enabled: boolean): Promise<boolean> {
|
|
92
|
+
const task = this.tasks.get(id)
|
|
93
|
+
if (!task) return false
|
|
94
|
+
task.enabled = enabled
|
|
95
|
+
// enable 时若 nextRunAt 已过期,重算,避免 enable 瞬间立即触发
|
|
96
|
+
if (enabled && task.nextRunAt < Date.now()) {
|
|
97
|
+
const schedule = task.schedule
|
|
98
|
+
if (schedule.mode === 'interval') {
|
|
99
|
+
task.nextRunAt = Date.now() + schedule.intervalMs
|
|
100
|
+
} else {
|
|
101
|
+
task.nextRunAt = (await computeNextCronRunAt(schedule.cronExpression, Date.now())) ?? Date.now()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.persist()
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
deleteTask(id: string): boolean {
|
|
109
|
+
const deleted = this.tasks.delete(id)
|
|
110
|
+
if (deleted) this.persist()
|
|
111
|
+
return deleted
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async runTaskNow(id: string): Promise<boolean> {
|
|
115
|
+
const task = this.tasks.get(id)
|
|
116
|
+
if (!task) return false
|
|
117
|
+
const dispatched = await this.dispatchTask(task)
|
|
118
|
+
this.persist()
|
|
119
|
+
return dispatched
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 调度 ──
|
|
123
|
+
|
|
124
|
+
startScheduler(): void {
|
|
125
|
+
if (this.tickTimer) return
|
|
126
|
+
this.tickTimer = setInterval(() => void this.tickScheduler(), TICK_INTERVAL_MS)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stopScheduler(): void {
|
|
130
|
+
if (this.tickTimer) {
|
|
131
|
+
clearInterval(this.tickTimer)
|
|
132
|
+
this.tickTimer = null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async tickScheduler(): Promise<void> {
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
|
|
139
|
+
// 1. 过期清理
|
|
140
|
+
for (const [id, task] of this.tasks) {
|
|
141
|
+
if (task.expiresAt && now >= task.expiresAt) {
|
|
142
|
+
this.tasks.delete(id)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. 标记到期
|
|
147
|
+
for (const task of this.tasks.values()) {
|
|
148
|
+
if (task.enabled && now >= task.nextRunAt) {
|
|
149
|
+
task.pending = true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. dispatch pending 任务(按 nextRunAt 排序)
|
|
154
|
+
const pending = [...this.tasks.values()]
|
|
155
|
+
.filter(t => t.pending)
|
|
156
|
+
.sort((a, b) => a.nextRunAt - b.nextRunAt)
|
|
157
|
+
|
|
158
|
+
for (const task of pending) {
|
|
159
|
+
if (task.pending) {
|
|
160
|
+
await this.dispatchTask(task)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.persist()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── dispatch ──
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* dispatch 单个任务。返回 true 表示真的发送了 message,false 表示 no-op
|
|
171
|
+
* (task disabled / rate-limited / 非 force 且 busy)。
|
|
172
|
+
* sendMessage 抛错时记录 failed 状态但不 rethrow,让 tick 继续处理其他任务。
|
|
173
|
+
*/
|
|
174
|
+
async dispatchTask(task: ScheduledTask): Promise<boolean> {
|
|
175
|
+
if (!task.enabled) return false
|
|
176
|
+
|
|
177
|
+
// 检查 force 或 idle
|
|
178
|
+
if (!task.force) {
|
|
179
|
+
if (!this.ctx.isIdle() || this.ctx.hasPendingMessages()) {
|
|
180
|
+
return false // 延迟到下次 tick
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 检查速率限制
|
|
185
|
+
if (!this.hasDispatchCapacity(Date.now())) return false
|
|
186
|
+
|
|
187
|
+
// 注入 message
|
|
188
|
+
try {
|
|
189
|
+
this.pi.sendMessage(
|
|
190
|
+
{ content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
|
|
191
|
+
{ deliverAs: 'followUp', triggerTurn: true },
|
|
192
|
+
)
|
|
193
|
+
} catch {
|
|
194
|
+
task.lastStatus = 'failed'
|
|
195
|
+
task.pending = false
|
|
196
|
+
task.history.push({ at: Date.now(), status: 'failed' })
|
|
197
|
+
if (task.history.length > 20) task.history.shift()
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 更新状态
|
|
202
|
+
task.runCount++
|
|
203
|
+
task.lastRunAt = Date.now()
|
|
204
|
+
task.lastStatus = 'success'
|
|
205
|
+
task.pending = false
|
|
206
|
+
task.history.push({ at: Date.now(), status: 'success' })
|
|
207
|
+
if (task.history.length > 20) task.history.shift()
|
|
208
|
+
|
|
209
|
+
// 计算下次执行
|
|
210
|
+
if (task.kind === 'once') {
|
|
211
|
+
this.tasks.delete(task.id)
|
|
212
|
+
} else {
|
|
213
|
+
const schedule = task.schedule
|
|
214
|
+
if (schedule.mode === 'interval') {
|
|
215
|
+
task.nextRunAt = Date.now() + schedule.intervalMs
|
|
216
|
+
} else {
|
|
217
|
+
task.nextRunAt = (await computeNextCronRunAt(schedule.cronExpression, Date.now())) ?? Date.now()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.dispatchTimestamps.push(Date.now())
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private hasDispatchCapacity(now: number): boolean {
|
|
226
|
+
const oneMinuteAgo = now - 60_000
|
|
227
|
+
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|
|
228
|
+
return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 持久化 ──
|
|
232
|
+
|
|
233
|
+
loadTasks(): void {
|
|
234
|
+
const store = this.store.load()
|
|
235
|
+
this.tasks = new Map(store.tasks.map(t => [t.id, t]))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
persist(): void {
|
|
239
|
+
const store: SchedulerStore = { version: 1, tasks: Array.from(this.tasks.values()) }
|
|
240
|
+
this.store.persist(store)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
persistSync(): void {
|
|
244
|
+
const store: SchedulerStore = { version: 1, tasks: Array.from(this.tasks.values()) }
|
|
245
|
+
this.store.persistSync(store)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── 工具方法 ──
|
|
249
|
+
|
|
250
|
+
getTaskCount(): number {
|
|
251
|
+
return this.tasks.size
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { ScheduledTask, SchedulerStore } from './types.js'
|
|
6
|
+
|
|
7
|
+
const HISTORY_LIMIT = 20
|
|
8
|
+
const DEBOUNCE_MS = 2000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 获取 store 文件路径:~/.pi/agent/scheduler/<root>/<segments>/scheduler.json
|
|
12
|
+
* workspace 路径隔离,不同 cwd 存不同文件。
|
|
13
|
+
*/
|
|
14
|
+
export function getStorePath(cwd: string): string {
|
|
15
|
+
const home = os.homedir()
|
|
16
|
+
const resolved = path.resolve(cwd)
|
|
17
|
+
const parsed = path.parse(resolved)
|
|
18
|
+
const segments = resolved.slice(parsed.root.length)
|
|
19
|
+
.split(path.sep).filter(Boolean)
|
|
20
|
+
const root = parsed.root
|
|
21
|
+
.replaceAll(/[^a-zA-Z0-9]+/g, '-')
|
|
22
|
+
.replaceAll(/^-+|-+$/g, '')
|
|
23
|
+
.toLowerCase() || 'root'
|
|
24
|
+
return path.join(home, '.pi', 'agent', 'scheduler', root, ...segments, 'scheduler.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* GC:裁剪 history > 20 条 + 移除过期任务
|
|
29
|
+
*/
|
|
30
|
+
function gc(store: SchedulerStore): SchedulerStore {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const tasks = store.tasks
|
|
33
|
+
.filter(t => !t.expiresAt || t.expiresAt > now)
|
|
34
|
+
.map(t => ({
|
|
35
|
+
...t,
|
|
36
|
+
history: t.history.slice(-HISTORY_LIMIT),
|
|
37
|
+
}))
|
|
38
|
+
return { ...store, tasks }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 创建 store 实例。
|
|
43
|
+
* load: 读取 JSON + 解析 + 降级
|
|
44
|
+
* persist: debounced 写入 + GC
|
|
45
|
+
* persistSync: 同步写入(session_shutdown 用)
|
|
46
|
+
*/
|
|
47
|
+
export function createStore(cwd: string) {
|
|
48
|
+
const storePath = getStorePath(cwd)
|
|
49
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
50
|
+
|
|
51
|
+
function ensureDir(): void {
|
|
52
|
+
const dir = path.dirname(storePath)
|
|
53
|
+
if (!fs.existsSync(dir)) {
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function load(): SchedulerStore {
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(storePath)) {
|
|
61
|
+
return { version: 1, tasks: [] }
|
|
62
|
+
}
|
|
63
|
+
const content = fs.readFileSync(storePath, 'utf-8')
|
|
64
|
+
const data = JSON.parse(content) as Partial<SchedulerStore>
|
|
65
|
+
// 版本迁移:缺失字段给默认值
|
|
66
|
+
return {
|
|
67
|
+
version: data.version ?? 1,
|
|
68
|
+
tasks: (data.tasks ?? []).map((t: Partial<ScheduledTask>) => ({
|
|
69
|
+
id: t.id ?? '',
|
|
70
|
+
name: t.name ?? '',
|
|
71
|
+
prompt: t.prompt ?? '',
|
|
72
|
+
kind: t.kind ?? 'recurring' as const,
|
|
73
|
+
schedule: t.schedule ?? { mode: 'interval' as const, intervalMs: 60000 },
|
|
74
|
+
createdAt: t.createdAt ?? 0,
|
|
75
|
+
nextRunAt: t.nextRunAt ?? 0,
|
|
76
|
+
runCount: t.runCount ?? 0,
|
|
77
|
+
enabled: t.enabled ?? true,
|
|
78
|
+
force: t.force ?? false,
|
|
79
|
+
history: t.history ?? [],
|
|
80
|
+
expiresAt: t.expiresAt,
|
|
81
|
+
lastRunAt: t.lastRunAt,
|
|
82
|
+
lastStatus: t.lastStatus,
|
|
83
|
+
})),
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// 文件损坏降级
|
|
87
|
+
console.warn(`[scheduler] Failed to load store from ${storePath}, using empty store`)
|
|
88
|
+
return { version: 1, tasks: [] }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeSync(store: SchedulerStore): void {
|
|
93
|
+
ensureDir()
|
|
94
|
+
const cleaned = gc(store)
|
|
95
|
+
fs.writeFileSync(storePath, JSON.stringify(cleaned, null, 2), 'utf-8')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function persist(store: SchedulerStore): void {
|
|
99
|
+
if (debounceTimer) {
|
|
100
|
+
clearTimeout(debounceTimer)
|
|
101
|
+
}
|
|
102
|
+
debounceTimer = setTimeout(() => {
|
|
103
|
+
writeSync(store)
|
|
104
|
+
debounceTimer = null
|
|
105
|
+
}, DEBOUNCE_MS)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function persistSync(store: SchedulerStore): void {
|
|
109
|
+
if (debounceTimer) {
|
|
110
|
+
clearTimeout(debounceTimer)
|
|
111
|
+
debounceTimer = null
|
|
112
|
+
}
|
|
113
|
+
writeSync(store)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { load, persist, persistSync, storePath }
|
|
117
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Static, Type } from '@sinclair/typebox'
|
|
2
|
+
|
|
3
|
+
import { formatRelativeTime, formatSchedule } from './format.js'
|
|
4
|
+
import { computeNextRuns, parseSchedule } from './parsing.js'
|
|
5
|
+
import type { SchedulerRuntime } from './runtime.js'
|
|
6
|
+
|
|
7
|
+
// TODO: add renderResult/renderCall to registerTool calls (standards.md §4.3)
|
|
8
|
+
|
|
9
|
+
// ── schedule tool ──
|
|
10
|
+
|
|
11
|
+
export const ScheduleParams = Type.Object({
|
|
12
|
+
prompt: Type.String({ description: 'Message to inject when the task fires.' }),
|
|
13
|
+
schedule: Type.String({ description: 'Schedule spec: duration (5m/2h/1d) for interval, or cron expression (*/10 * * * *).' }),
|
|
14
|
+
kind: Type.Optional(Type.Union([Type.Literal('once'), Type.Literal('recurring')], { description: 'Task kind. Default: recurring.' })),
|
|
15
|
+
name: Type.Optional(Type.String({ description: 'Human-readable task name. Auto-generated from prompt if omitted.' })),
|
|
16
|
+
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable.' })),
|
|
17
|
+
force: Type.Optional(Type.Boolean({ description: 'Dispatch even when agent is busy. Default: false.' })),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export type ScheduleParamsT = Static<typeof ScheduleParams>
|
|
21
|
+
|
|
22
|
+
export const scheduleGuidelines = [
|
|
23
|
+
'This tool creates a scheduled task.',
|
|
24
|
+
'Schedule accepts duration (5m, 2h, 1d) for interval-based or cron expression for time-based.',
|
|
25
|
+
'Default kind is recurring. Set kind="once" for one-time reminders.',
|
|
26
|
+
'After creation, the response includes task id and next 5 run times.',
|
|
27
|
+
'Default expiry is 7 days. Use expires="never" for long-term tasks.',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function createScheduleHandler(runtime: SchedulerRuntime) {
|
|
31
|
+
return async (params: ScheduleParamsT) => {
|
|
32
|
+
const { prompt, schedule: scheduleInput, kind, name, expires, force } = params
|
|
33
|
+
|
|
34
|
+
const parsed = await parseSchedule(scheduleInput)
|
|
35
|
+
if (!parsed) {
|
|
36
|
+
throw new Error(`Invalid schedule: "${scheduleInput}". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const task = await runtime.addTask(prompt, parsed.spec, { kind, name, expires, force })
|
|
40
|
+
|
|
41
|
+
const nextRuns = await computeNextRuns(task.schedule, Date.now(), 5)
|
|
42
|
+
const summary = [
|
|
43
|
+
`Task "${task.name}" (${task.id}) created.`,
|
|
44
|
+
`Schedule: ${formatSchedule(task.schedule)}`,
|
|
45
|
+
`Kind: ${task.kind}`,
|
|
46
|
+
`Expires: ${task.expiresAt ? formatRelativeTime(task.expiresAt) : 'never'}`,
|
|
47
|
+
`Force: ${task.force ? 'yes' : 'no'}`,
|
|
48
|
+
'',
|
|
49
|
+
'Next 5 runs:',
|
|
50
|
+
...nextRuns.map((t, i) => ` ${i + 1}. ${formatRelativeTime(t)}`),
|
|
51
|
+
].join('\n')
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text' as const, text: summary }],
|
|
55
|
+
details: { task, nextRuns },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── schedule_control tool ──
|
|
61
|
+
|
|
62
|
+
export const ScheduleControlParams = Type.Object({
|
|
63
|
+
action: Type.Union([Type.Literal('list'), Type.Literal('toggle'), Type.Literal('delete'), Type.Literal('run')], { description: 'Action to perform.' }),
|
|
64
|
+
id: Type.Optional(Type.String({ description: 'Task id. Required for toggle/delete/run.' })),
|
|
65
|
+
enabled: Type.Optional(Type.Boolean({ description: 'Target enabled state. Required for toggle.' })),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export type ScheduleControlParamsT = Static<typeof ScheduleControlParams>
|
|
69
|
+
|
|
70
|
+
export const controlGuidelines = [
|
|
71
|
+
'Use action="list" to see all scheduled tasks.',
|
|
72
|
+
'After listing, use the returned id for toggle/delete/run.',
|
|
73
|
+
'Prefer toggle(enabled=false) over delete for temporary pauses.',
|
|
74
|
+
'action="run" fires the task immediately.',
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
export function createScheduleControlHandler(runtime: SchedulerRuntime) {
|
|
78
|
+
return async (params: ScheduleControlParamsT) => {
|
|
79
|
+
const { action, id, enabled } = params
|
|
80
|
+
|
|
81
|
+
switch (action) {
|
|
82
|
+
case 'list': {
|
|
83
|
+
const tasks = runtime.listTasks()
|
|
84
|
+
if (tasks.length === 0) {
|
|
85
|
+
return { content: [{ type: 'text' as const, text: 'No scheduled tasks.' }], details: { tasks: [] } }
|
|
86
|
+
}
|
|
87
|
+
const lines = tasks.map(t =>
|
|
88
|
+
`${t.enabled ? '●' : '○'} ${t.id} ${t.name} · ${formatSchedule(t.schedule)} · ${formatRelativeTime(t.nextRunAt)}`
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: 'text' as const, text: lines.join('\n') }],
|
|
92
|
+
details: { tasks },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'toggle': {
|
|
97
|
+
if (!id) throw new Error('id is required for toggle.')
|
|
98
|
+
if (enabled === undefined) throw new Error('enabled is required for toggle.')
|
|
99
|
+
const success = await runtime.toggleTask(id, enabled)
|
|
100
|
+
if (!success) throw new Error(`Task ${id} not found.`)
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: 'text' as const, text: `Task ${id} ${enabled ? 'enabled' : 'disabled'}.` }],
|
|
103
|
+
details: { success },
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'delete': {
|
|
108
|
+
if (!id) throw new Error('id is required for delete.')
|
|
109
|
+
const success = runtime.deleteTask(id)
|
|
110
|
+
if (!success) throw new Error(`Task ${id} not found.`)
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: 'text' as const, text: `Task ${id} deleted.` }],
|
|
113
|
+
details: { success },
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'run': {
|
|
118
|
+
if (!id) throw new Error('id is required for run.')
|
|
119
|
+
const success = await runtime.runTaskNow(id)
|
|
120
|
+
if (!success) throw new Error(`Task ${id} not found.`)
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: 'text' as const, text: `Task ${id} executed.` }],
|
|
123
|
+
details: { success },
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`Unknown action: ${action}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ── 调度规格 ──
|
|
2
|
+
|
|
3
|
+
export type ScheduleMode = 'cron' | 'interval'
|
|
4
|
+
|
|
5
|
+
export type ScheduleSpec =
|
|
6
|
+
| { mode: 'cron'; cronExpression: string }
|
|
7
|
+
| { mode: 'interval'; intervalMs: number }
|
|
8
|
+
|
|
9
|
+
// ── 任务 ──
|
|
10
|
+
|
|
11
|
+
export type TaskKind = 'once' | 'recurring'
|
|
12
|
+
export type TaskStatus = 'pending' | 'running' | 'success' | 'failed'
|
|
13
|
+
|
|
14
|
+
export interface ScheduledTask {
|
|
15
|
+
id: string // 8 位 hex,自动生成
|
|
16
|
+
name: string // 可读名称(用户指定或从 prompt 自动截取前 30 字)
|
|
17
|
+
prompt: string // 到期时注入的 message
|
|
18
|
+
kind: TaskKind
|
|
19
|
+
schedule: ScheduleSpec // once 时 intervalMs = delayMs
|
|
20
|
+
enabled: boolean
|
|
21
|
+
force: boolean // true = 即使 agent busy 也 dispatch
|
|
22
|
+
createdAt: number
|
|
23
|
+
nextRunAt: number
|
|
24
|
+
expiresAt?: number // undefined = 永不过期
|
|
25
|
+
runCount: number
|
|
26
|
+
lastRunAt?: number
|
|
27
|
+
lastStatus?: TaskStatus
|
|
28
|
+
history: ExecutionRecord[] // 最近 20 条
|
|
29
|
+
pending?: boolean // 标记到期待 dispatch
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExecutionRecord {
|
|
33
|
+
at: number
|
|
34
|
+
status: TaskStatus
|
|
35
|
+
snippet?: string // agent 回复前 100 字
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── 持久化 ──
|
|
39
|
+
|
|
40
|
+
export interface SchedulerStore {
|
|
41
|
+
version: 1
|
|
42
|
+
tasks: ScheduledTask[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 解析结果 ──
|
|
46
|
+
|
|
47
|
+
export interface ParseScheduleResult {
|
|
48
|
+
spec: ScheduleSpec
|
|
49
|
+
note?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── 添加选项 ──
|
|
53
|
+
|
|
54
|
+
export interface AddOptions {
|
|
55
|
+
name?: string
|
|
56
|
+
kind?: TaskKind
|
|
57
|
+
expires?: string
|
|
58
|
+
force?: boolean
|
|
59
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { formatRelativeTime, truncate } from './format.js'
|
|
2
|
+
import type { ScheduledTask } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 渲染 TUI status bar widget(string[],配合 SDK setWidget 第一重载)。
|
|
6
|
+
* 格式:[scheduler] 3 scheduled · check-build in 4m · 1 overdue
|
|
7
|
+
*
|
|
8
|
+
* 不接受 theme 参数:string[] 重载本身不提供 theme,着色交给 Pi 默认渲染。
|
|
9
|
+
* overdue 用 [!] 纯文本标记(PR 已统一去 emoji)。
|
|
10
|
+
*/
|
|
11
|
+
export function renderSchedulerWidget(tasks: ScheduledTask[]): string[] {
|
|
12
|
+
if (tasks.length === 0) return []
|
|
13
|
+
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
const enabled = tasks.filter(t => t.enabled)
|
|
16
|
+
const overdue = enabled.filter(t => t.nextRunAt <= now)
|
|
17
|
+
const upcoming = enabled
|
|
18
|
+
.filter(t => t.nextRunAt > now)
|
|
19
|
+
.sort((a, b) => a.nextRunAt - b.nextRunAt)
|
|
20
|
+
|
|
21
|
+
const parts: string[] = []
|
|
22
|
+
parts.push(`${enabled.length} scheduled`)
|
|
23
|
+
|
|
24
|
+
if (upcoming.length > 0) {
|
|
25
|
+
const next = upcoming[0]!
|
|
26
|
+
parts.push(`${truncate(next.name, 20)} ${formatRelativeTime(next.nextRunAt)}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (overdue.length > 0) {
|
|
30
|
+
parts.push(`[!] ${overdue.length} overdue`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return [`[scheduler] ${parts.join(' · ')}`]
|
|
34
|
+
}
|