@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/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
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/__tests__/**/*.test.ts'],
6
+ },
7
+ })