@zhushanwen/pi-scheduler 0.1.0 → 0.2.0

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,214 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+
3
+ import { MockSchedulerBackend } from '../backend.js'
4
+ import { SchedulerRuntime } from '../runtime.js'
5
+ import { SchedulerService } from '../service.js'
6
+
7
+ const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
+
9
+ describe('SchedulerService', () => {
10
+ let service: SchedulerService
11
+ let backend: MockSchedulerBackend
12
+
13
+ beforeEach(() => {
14
+ backend = new MockSchedulerBackend()
15
+ service = new SchedulerService(new SchedulerRuntime(backend, mockCtx), () => backend.now())
16
+ })
17
+
18
+ describe('create', () => {
19
+ it('creates task with duration and returns full summary', async () => {
20
+ const result = await service.create('check build', '5m')
21
+ expect(result.success).toBe(true)
22
+ expect(result.errorCode).toBeUndefined()
23
+ expect(result.message).toContain('Task "check build"')
24
+ expect(result.message).toContain('every 5m')
25
+ expect(result.message).toContain('Next 5 runs:')
26
+ expect(result.data!.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
27
+ expect(result.data!.nextRuns).toHaveLength(5)
28
+ // TC-RECURRING-NO-REGRESS:recurring 回显仍为 5 行编号 run 行(编号列表不变)
29
+ const runLines = result.message.split('\n').filter(l => /^\s+\d+\./.test(l))
30
+ expect(runLines).toHaveLength(5)
31
+ })
32
+
33
+ // TC-ONCE-ECHO:once 任务回显仅 1 条 run 行(单行内联,无编号列表)
34
+ it('TC-ONCE-ECHO: once task echoes single run line without numbered list', async () => {
35
+ const result = await service.create('git pull', '1h', { kind: 'once' })
36
+ expect(result.success).toBe(true)
37
+ expect(result.message).toContain('once in 1h')
38
+ expect(result.message).not.toContain('Next 5 runs:')
39
+ expect(result.message).toContain('Next run: in 1h')
40
+ // 无编号 run 行(once 单行内联)
41
+ expect(result.message).not.toMatch(/^\s+\d+\./m)
42
+ // nextRuns 数据同步裁剪
43
+ expect(result.data!.nextRuns).toHaveLength(1)
44
+ })
45
+
46
+ // TC-NOW-INJECT:create 用注入的 now 源(backend.now())而非 Date.now()
47
+ it('TC-NOW-INJECT: create uses injected now source', async () => {
48
+ const fixedNow = Date.now()
49
+ const injectBackend = new MockSchedulerBackend()
50
+ injectBackend.nowValue = fixedNow
51
+ const nowService = new SchedulerService(new SchedulerRuntime(injectBackend, mockCtx), () => injectBackend.now())
52
+ const result = await nowService.create('one shot', '1h', { kind: 'once' })
53
+ expect(result.data!.nextRuns[0]).toBe(fixedNow + 3_600_000)
54
+ expect(result.message).toContain('in 1h')
55
+ })
56
+
57
+ it('creates cron task', async () => {
58
+ const result = await service.create('standup', '0 9 * * 1-5')
59
+ expect(result.success).toBe(true)
60
+ expect(result.data!.task.schedule).toEqual({ mode: 'cron', cronExpression: '0 0 9 * * 1-5' })
61
+ })
62
+
63
+ it('returns INVALID_SCHEDULE for invalid schedule', async () => {
64
+ const result = await service.create('test', 'invalid')
65
+ expect(result).toEqual({
66
+ success: false,
67
+ errorCode: 'INVALID_SCHEDULE',
68
+ message: 'Invalid schedule: "invalid". Use duration (5m/2h/1d) or cron expression (*/10 * * * *).',
69
+ })
70
+ })
71
+
72
+ it('returns TASK_LIMIT_REACHED when 50 tasks exist', async () => {
73
+ for (let i = 0; i < 50; i++) {
74
+ await service.create(`task ${i}`, '5m')
75
+ }
76
+ const result = await service.create('one more', '5m')
77
+ expect(result.success).toBe(false)
78
+ expect(result.errorCode).toBe('TASK_LIMIT_REACHED')
79
+ expect(result.message).toContain('Task limit reached (50)')
80
+ })
81
+ })
82
+
83
+ describe('list', () => {
84
+ it('returns empty message when no tasks', () => {
85
+ expect(service.list()).toEqual({
86
+ success: true,
87
+ message: 'No scheduled tasks.',
88
+ data: { tasks: [] },
89
+ })
90
+ })
91
+
92
+ it('returns formatted lines when tasks exist', async () => {
93
+ await service.create('check build', '5m')
94
+ const result = service.list()
95
+ expect(result.success).toBe(true)
96
+ expect(result.message).toContain('check build')
97
+ expect(result.message).toContain('every 5m')
98
+ expect(result.data!.tasks).toHaveLength(1)
99
+ })
100
+
101
+ it('marks disabled tasks with ○', async () => {
102
+ const created = await service.create('paused', '5m')
103
+ await service.toggle(created.data!.task.id, false)
104
+ const result = service.list()
105
+ expect(result.message).toContain('○')
106
+ })
107
+ })
108
+
109
+ describe('toggle', () => {
110
+ it('toggles task', async () => {
111
+ const created = await service.create('test', '5m')
112
+ const id = created.data!.task.id
113
+ const result = await service.toggle(id, false)
114
+ expect(result).toEqual({ success: true, message: `Task ${id} disabled.` })
115
+ expect(service.runtime.getTask(id)?.enabled).toBe(false)
116
+ })
117
+
118
+ it('TC4: returns TASK_NOT_FOUND for unknown id', async () => {
119
+ const result = await service.toggle('deadbeef', true)
120
+ expect(result).toEqual({
121
+ success: false,
122
+ errorCode: 'TASK_NOT_FOUND',
123
+ message: 'Task deadbeef not found.',
124
+ })
125
+ })
126
+
127
+ it('returns INVALID_PARAMS when id missing', async () => {
128
+ const result = await service.toggle(undefined, true)
129
+ expect(result).toEqual({
130
+ success: false,
131
+ errorCode: 'INVALID_PARAMS',
132
+ message: 'id is required for toggle.',
133
+ })
134
+ })
135
+
136
+ it('returns INVALID_PARAMS when enabled missing', async () => {
137
+ const result = await service.toggle('abc12345', undefined)
138
+ expect(result).toEqual({
139
+ success: false,
140
+ errorCode: 'INVALID_PARAMS',
141
+ message: 'enabled is required for toggle.',
142
+ })
143
+ })
144
+ })
145
+
146
+ describe('delete', () => {
147
+ it('deletes task', async () => {
148
+ const created = await service.create('test', '5m')
149
+ const id = created.data!.task.id
150
+ const result = service.delete(id)
151
+ expect(result).toEqual({ success: true, message: `Task ${id} deleted.` })
152
+ expect(service.runtime.getTask(id)).toBeUndefined()
153
+ })
154
+
155
+ it('TC4: returns TASK_NOT_FOUND for unknown id', () => {
156
+ const result = service.delete('deadbeef')
157
+ expect(result).toEqual({
158
+ success: false,
159
+ errorCode: 'TASK_NOT_FOUND',
160
+ message: 'Task deadbeef not found.',
161
+ })
162
+ })
163
+ })
164
+
165
+ describe('run', () => {
166
+ it('runs task now', async () => {
167
+ const created = await service.create('test', '5m')
168
+ const id = created.data!.task.id
169
+ const result = await service.run(id)
170
+ expect(result).toEqual({ success: true, message: `Task ${id} executed.` })
171
+ expect(service.runtime.getTask(id)?.runCount).toBe(1)
172
+ })
173
+
174
+ it('TC4: returns TASK_NOT_FOUND for unknown id', async () => {
175
+ const result = await service.run('deadbeef')
176
+ expect(result).toEqual({
177
+ success: false,
178
+ errorCode: 'TASK_NOT_FOUND',
179
+ message: 'Task deadbeef not found.',
180
+ })
181
+ })
182
+
183
+ it('returns DISPATCH_SKIPPED for disabled task (not not-found)', async () => {
184
+ const created = await service.create('test', '5m')
185
+ const id = created.data!.task.id
186
+ await service.toggle(id, false)
187
+ const result = await service.run(id)
188
+ expect(result).toEqual({
189
+ success: false,
190
+ errorCode: 'DISPATCH_SKIPPED',
191
+ message: `Task ${id} not dispatched (busy, disabled, or rate-limited).`,
192
+ })
193
+ })
194
+
195
+ // TC8:busy 场景(任务 enabled 但 ctx.isIdle() 返回 false)→ dispatch no-op →
196
+ // DISPATCH_SKIPPED。m2 只覆盖了 disabled 场景,busy 是 dispatchTask 的
197
+ // `!task.force && (!ctx.isIdle() || ctx.hasPendingMessages())` 独立分支。
198
+ it('returns DISPATCH_SKIPPED when ctx is busy (isIdle=false)', async () => {
199
+ const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
200
+ const busyBackend = new MockSchedulerBackend()
201
+ const busyService = new SchedulerService(new SchedulerRuntime(busyBackend, busyCtx), () => busyBackend.now())
202
+
203
+ const created = await busyService.create('test', '5m')
204
+ const id = created.data!.task.id
205
+ const result = await busyService.run(id)
206
+
207
+ expect(result.success).toBe(false)
208
+ expect(result.errorCode).toBe('DISPATCH_SKIPPED')
209
+ expect(result.message).toContain('busy')
210
+ // 未发送任何 message(dispatch no-op)
211
+ expect(busyBackend.sentMessages).toHaveLength(0)
212
+ })
213
+ })
214
+ })
@@ -1,45 +1,52 @@
1
- import { beforeEach,describe, expect, it, vi } from 'vitest'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
+ import { MockSchedulerBackend } from '../backend.js'
3
4
  import { SchedulerRuntime } from '../runtime.js'
4
- import { createScheduleControlHandler,createScheduleHandler } from '../tool.js'
5
+ import { SchedulerService } from '../service.js'
6
+ import { createScheduleControlHandler, createScheduleHandler } from '../tool.js'
5
7
 
6
- const mockPi = { sendMessage: vi.fn() }
7
8
  const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
8
9
 
9
10
  describe('schedule tool', () => {
10
- let runtime: SchedulerRuntime
11
+ let service: SchedulerService
11
12
  let handler: ReturnType<typeof createScheduleHandler>
12
13
 
13
14
  beforeEach(() => {
14
15
  vi.clearAllMocks()
15
- runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
16
- handler = createScheduleHandler(runtime)
16
+ const backend = new MockSchedulerBackend()
17
+ service = new SchedulerService(new SchedulerRuntime(backend, mockCtx), () => backend.now())
18
+ handler = createScheduleHandler(service)
17
19
  })
18
20
 
19
21
  it('creates task with duration', async () => {
20
22
  const result = await handler({ prompt: 'check build', schedule: '5m' })
21
23
  expect(result.content[0]!.text).toContain('Task "check build"')
22
- expect(result.details.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
24
+ const details = result.details as { task: { schedule: { mode: string; intervalMs: number } } }
25
+ expect(details.task.schedule).toEqual({ mode: 'interval', intervalMs: 300000 })
23
26
  })
24
27
 
25
- it('throws for invalid schedule', async () => {
26
- await expect(handler({ prompt: 'test', schedule: 'invalid' }))
27
- .rejects.toThrow('Invalid schedule')
28
+ it('invalid schedule returns isError with message (not throw)', async () => {
29
+ const result = await handler({ prompt: 'test', schedule: 'invalid' })
30
+ expect(result.isError).toBe(true)
31
+ expect(result.content[0]!.text).toContain('Invalid schedule')
32
+ const details = result.details as { errorCode: string }
33
+ expect(details.errorCode).toBe('INVALID_SCHEDULE')
28
34
  })
29
35
  })
30
36
 
31
37
  describe('schedule_control tool', () => {
32
- let runtime: SchedulerRuntime
38
+ let service: SchedulerService
33
39
  let handler: ReturnType<typeof createScheduleControlHandler>
34
40
 
35
41
  beforeEach(() => {
36
42
  vi.clearAllMocks()
37
- runtime = new SchedulerRuntime('/test', mockPi as never, mockCtx as never)
38
- handler = createScheduleControlHandler(runtime)
43
+ const backend = new MockSchedulerBackend()
44
+ service = new SchedulerService(new SchedulerRuntime(backend, mockCtx), () => backend.now())
45
+ handler = createScheduleControlHandler(service)
39
46
  })
40
47
 
41
48
  it('lists tasks', async () => {
42
- await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
49
+ await service.create('test', '5m')
43
50
  const result = await handler({ action: 'list' })
44
51
  expect(result.content[0]!.text).toContain('test')
45
52
  })
@@ -50,25 +57,35 @@ describe('schedule_control tool', () => {
50
57
  })
51
58
 
52
59
  it('toggles task', async () => {
53
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
54
- const result = await handler({ action: 'toggle', id: task.id, enabled: false })
60
+ const created = await service.create('test', '5m')
61
+ const result = await handler({ action: 'toggle', id: created.data!.task.id, enabled: false })
55
62
  expect(result.content[0]!.text).toContain('disabled')
56
63
  })
57
64
 
58
- it('throws for missing id on toggle', async () => {
59
- await expect(handler({ action: 'toggle', enabled: true }))
60
- .rejects.toThrow('id is required')
65
+ it('missing id on toggle returns isError', async () => {
66
+ const result = await handler({ action: 'toggle', enabled: true })
67
+ expect(result.isError).toBe(true)
68
+ expect(result.content[0]!.text).toContain('id is required')
69
+ })
70
+
71
+ // TC5 tool 侧:toggle 不存在 id → 结构化 isError + TASK_NOT_FOUND
72
+ it('TC5: toggle unknown id returns isError with TASK_NOT_FOUND', async () => {
73
+ const result = await handler({ action: 'toggle', id: 'deadbeef', enabled: false })
74
+ expect(result.isError).toBe(true)
75
+ const details = result.details as { errorCode: string }
76
+ expect(details.errorCode).toBe('TASK_NOT_FOUND')
77
+ expect(result.content[0]!.text).toBe('Task deadbeef not found.')
61
78
  })
62
79
 
63
80
  it('deletes task', async () => {
64
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
65
- const result = await handler({ action: 'delete', id: task.id })
81
+ const created = await service.create('test', '5m')
82
+ const result = await handler({ action: 'delete', id: created.data!.task.id })
66
83
  expect(result.content[0]!.text).toContain('deleted')
67
84
  })
68
85
 
69
86
  it('runs task now', async () => {
70
- const task = await runtime.addTask('test', { mode: 'interval', intervalMs: 60000 })
71
- const result = await handler({ action: 'run', id: task.id })
87
+ const created = await service.create('test', '5m')
88
+ const result = await handler({ action: 'run', id: created.data!.task.id })
72
89
  expect(result.content[0]!.text).toContain('executed')
73
90
  })
74
91
  })
package/src/backend.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+
3
+ import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
4
+ import type { ScheduledTask, SchedulerEntryOp } from './types.js'
5
+
6
+ // ── SchedulerBackend 接口 ──
7
+
8
+ /**
9
+ * 运行时协作后端抽象(依赖反转)。SchedulerRuntime 只依赖此接口:
10
+ * 不触碰 session JSONL、不持有 pi。
11
+ *
12
+ * - sendMessage: 到期 dispatch 的消息注入(生产实现委托 pi.sendMessage)
13
+ * - appendEntry: 按 op 写 pi-scheduler:task custom entry(event sourcing)。
14
+ * 生产实现委托 pi.appendEntry(同步落盘)。失败必须被调用方 try-catch(ER-APPEND-FAIL:
15
+ * runtime 捕获后 console.warn + 不 rethrow,内存态已更新,at-least-once 已知恶化窗口)
16
+ * - getSessionFile: 当前 session JSONL 路径(addTask 构建 upsert op 的 ownerSessionFile 用;
17
+ * --no-session 模式返回 undefined,调用方 ?? '' 兜底)
18
+ * - now: 时间源(测试可注入固定值)
19
+ *
20
+ * 读路径不在接口:loadTasks 由 PiSchedulerBackend 类方法承担(非接口成员),内部委托
21
+ * replayFoldEntries(折叠当前 session 的 custom entries 恢复任务),index.ts 装配点调用后
22
+ * 经 runtime.loadTasks(tasks) 注入。append-only 模型无需全量 persist——runtime 按 op 调
23
+ * appendEntry,replay 重放恢复,故 persist/persistSync 已移除。
24
+ *
25
+ * sendMessage 的 msg 签名与 pi 的 CustomMessage 对齐(customType/display 必填):
26
+ * 调用方必须显式提供,PiSchedulerBackend 直接透传无需兜底默认值。
27
+ */
28
+ export interface SchedulerBackend {
29
+ sendMessage(
30
+ msg: { content: string; customType: string; display: boolean },
31
+ opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
32
+ ): Promise<void>
33
+ appendEntry(op: SchedulerEntryOp): void
34
+ getSessionFile(): string | undefined
35
+ now(): number
36
+ }
37
+
38
+ /**
39
+ * ctx.sessionManager 的最小可识别形状(duck-typed)。真实 ExtensionContext.sessionManager
40
+ * 返回 pi 的 SessionManager(getEntries(): SessionEntry[]、getSessionFile(): string|undefined),
41
+ * 结构兼容本接口。PiSchedulerBackend 只依赖这两个方法。
42
+ */
43
+ export interface SchedulerBackendCtx {
44
+ sessionManager: {
45
+ getEntries(): SchedulerEntryLike[] | Iterable<SchedulerEntryLike>
46
+ getSessionFile(): string | undefined
47
+ }
48
+ }
49
+
50
+ // ── 生产实现 ──
51
+
52
+ /**
53
+ * 生产后端:pi.appendEntry(写 custom entry 到 owner session JSONL)+ pi.sendMessage + Date.now()。
54
+ *
55
+ * 任务状态以 append-only event sourcing 持久化:runtime 各操作调 appendEntry 写 op,
56
+ * session_start 时 loadTasks 经 replayFoldEntries 折叠历史 entries 恢复。不再持有 store 文件、
57
+ * 不再全量 persist/persistSync。
58
+ */
59
+ export class PiSchedulerBackend implements SchedulerBackend {
60
+ private ctx: SchedulerBackendCtx
61
+ private pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>
62
+
63
+ constructor(ctx: SchedulerBackendCtx, pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>) {
64
+ this.ctx = ctx
65
+ this.pi = pi
66
+ }
67
+
68
+ /**
69
+ * 读路径:折叠当前 session 的 pi-scheduler:task custom entries 恢复任务(非接口成员,
70
+ * 由装配点 session_start 调用)。replayFoldEntries 内部含 fork owner 过滤与异常兜底。
71
+ */
72
+ loadTasks(): ScheduledTask[] {
73
+ return [
74
+ ...replayFoldEntries(this.ctx.sessionManager.getEntries(), this.ctx.sessionManager.getSessionFile()).values(),
75
+ ]
76
+ }
77
+
78
+ async sendMessage(
79
+ msg: { content: string; customType: string; display: boolean },
80
+ opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
81
+ ): Promise<void> {
82
+ await this.pi.sendMessage(msg, opts)
83
+ }
84
+
85
+ appendEntry(op: SchedulerEntryOp): void {
86
+ this.pi.appendEntry('pi-scheduler:task', op)
87
+ }
88
+
89
+ getSessionFile(): string | undefined {
90
+ return this.ctx.sessionManager.getSessionFile()
91
+ }
92
+
93
+ now(): number {
94
+ return Date.now()
95
+ }
96
+ }
97
+
98
+ // ── 测试实现 ──
99
+
100
+ export interface SentMessage {
101
+ msg: { content: string; customType: string; display: boolean }
102
+ opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean }
103
+ }
104
+
105
+ /**
106
+ * Mock 后端:零 session/FS 副作用,记录 sendMessage/appendEntry 调用,支持注入固定时间与 fake entries。
107
+ * 与 SchedulerBackend/PiSchedulerBackend 同文件 export(测试从 './backend.js' import)。
108
+ *
109
+ * 能力:
110
+ * - sentMessages: 每次 sendMessage 的 {msg, opts} 记录
111
+ * - appendedOps: 每次 appendEntry 收到的 SchedulerEntryOp(测 runtime 各 op 断言)
112
+ * - fakeEntries / fakeSessionFile: loadTasks 经 replayFoldEntries 的注入源(测 backend→replay 委托)
113
+ * - nowValue: now() 返回固定值,缺省 Date.now()
114
+ * - appendError: 注入后 appendEntry 抛该错(测 ER-APPEND-FAIL 捕获路径)
115
+ */
116
+ export class MockSchedulerBackend implements SchedulerBackend {
117
+ sentMessages: SentMessage[] = []
118
+ appendedOps: SchedulerEntryOp[] = []
119
+ fakeEntries: SchedulerEntryLike[] = []
120
+ fakeSessionFile: string | undefined = '/test/session.json'
121
+ nowValue: number | undefined
122
+ appendError: Error | null = null
123
+
124
+ async sendMessage(
125
+ msg: { content: string; customType: string; display: boolean },
126
+ opts?: { deliverAs?: 'followUp'; triggerTurn?: boolean },
127
+ ): Promise<void> {
128
+ this.sentMessages.push({ msg, opts })
129
+ }
130
+
131
+ appendEntry(op: SchedulerEntryOp): void {
132
+ if (this.appendError) throw this.appendError
133
+ this.appendedOps.push(op)
134
+ }
135
+
136
+ getSessionFile(): string | undefined {
137
+ return this.fakeSessionFile
138
+ }
139
+
140
+ now(): number {
141
+ return this.nowValue ?? Date.now()
142
+ }
143
+
144
+ /**
145
+ * 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
146
+ * fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
147
+ */
148
+ loadTasks(): ScheduledTask[] {
149
+ return [...replayFoldEntries(this.fakeEntries, this.fakeSessionFile).values()]
150
+ }
151
+ }
package/src/commands.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
2
2
 
3
- import { formatRelativeTime, formatSchedule } from './format.js'
4
- import { parseSchedule } from './parsing.js'
5
- import type { SchedulerRuntime } from './runtime.js'
3
+ import { formatSchedule } from './format.js'
4
+ import type { SchedulerService } from './service.js'
6
5
 
7
6
  /**
8
7
  * Shell-style quote-aware tokenizer.
@@ -41,17 +40,17 @@ function tokenizeQuoted(input: string): string[] {
41
40
  * 注册 /schedule command。
42
41
  * 消歧规则:第一个参数匹配子命令关键词则走对应分支,否则尝试 parseSchedule 创建任务。
43
42
  *
44
- * runtime 通过 getter 获取:registerScheduleCommand 在 factory 顶层调用,此时 session_start
45
- * 尚未触发、runtime 还是 null。getArgumentCompletions / handler 真正执行时才读 runtime 当前值。
43
+ * service 通过 getter 获取:registerScheduleCommand 在 factory 顶层调用,此时 session_start
44
+ * 尚未触发、service 还是 null。getArgumentCompletions / handler 真正执行时才读 service 当前值。
46
45
  */
47
46
  export function registerScheduleCommand(
48
47
  pi: ExtensionAPI,
49
- getRuntime: () => SchedulerRuntime | null,
48
+ getService: () => SchedulerService | null,
50
49
  ) {
51
50
  pi.registerCommand('schedule', {
52
51
  description: 'Manage scheduled tasks. No args opens TUI. /schedule <schedule> <prompt> to create.',
53
52
  getArgumentCompletions(prefix: string) {
54
- const runtime = getRuntime()
53
+ const service = getService()
55
54
  const trimmed = prefix.trimStart()
56
55
  const parts = trimmed.split(/\s+/).filter(Boolean)
57
56
  if (parts.length <= 1) {
@@ -66,17 +65,20 @@ export function registerScheduleCommand(
66
65
  ].filter(opt => opt.label.startsWith(trimmed.toLowerCase()))
67
66
  }
68
67
  // 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
- }))
68
+ if (['on', 'off', 'rm', 'run'].includes(parts[0]!) && service) {
69
+ const result = service.list()
70
+ if (result.success && result.data) {
71
+ return result.data.tasks.map(t => ({
72
+ label: t.id,
73
+ value: t.id,
74
+ description: `${t.name} · ${formatSchedule(t.schedule, t.kind)}`
75
+ }))
76
+ }
75
77
  }
76
78
  return null
77
79
  },
78
80
  handler: async (args: string, ctx: ExtensionCommandContext) => {
79
- const result = await executeScheduleCommand(getRuntime(), args)
81
+ const result = await executeScheduleCommand(getService(), args)
80
82
  ctx.ui.notify(result, 'info')
81
83
  },
82
84
  })
@@ -85,12 +87,14 @@ export function registerScheduleCommand(
85
87
  /**
86
88
  * Core logic for /schedule command. Extracted for testability (handler returns
87
89
  * void per SDK contract; tests call this function directly to assert output).
90
+ *
91
+ * 命令层只保留参数路由与 usage 文案(C6),业务调用全部走 SchedulerService。
88
92
  */
89
93
  export async function executeScheduleCommand(
90
- runtime: SchedulerRuntime | null,
94
+ service: SchedulerService | null,
91
95
  args: string,
92
96
  ): Promise<string> {
93
- if (!runtime) return 'Scheduler not initialized: session not started.'
97
+ if (!service) return 'Scheduler not initialized: session not started.'
94
98
 
95
99
  const trimmed = args.trim()
96
100
  if (!trimmed) {
@@ -103,32 +107,27 @@ export async function executeScheduleCommand(
103
107
 
104
108
  // 子命令路由
105
109
  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')
110
+ return service.list().message
111
111
  }
112
112
 
113
113
  if (first === 'on' || first === 'off') {
114
114
  const id = parts[1]
115
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.`
116
+ const result = await service.toggle(id, first === 'on')
117
+ return result.message
118
118
  }
119
119
 
120
120
  if (first === 'rm') {
121
121
  const id = parts[1]
122
122
  if (!id) return 'Usage: /schedule rm <id>'
123
- const success = runtime.deleteTask(id)
124
- return success ? `Task ${id} deleted.` : `Task ${id} not found.`
123
+ return service.delete(id).message
125
124
  }
126
125
 
127
126
  if (first === 'run') {
128
127
  const id = parts[1]
129
128
  if (!id) return 'Usage: /schedule run <id>'
130
- const success = await runtime.runTaskNow(id)
131
- return success ? `Task ${id} executed.` : `Task ${id} not found.`
129
+ const result = await service.run(id)
130
+ return result.message
132
131
  }
133
132
 
134
133
  // 创建任务分支
@@ -140,11 +139,6 @@ export async function executeScheduleCommand(
140
139
  const prompt = parts.slice(scheduleStart + 1).join(' ')
141
140
  if (!prompt) return 'Usage: /schedule <schedule> <prompt>'
142
141
 
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)}`
142
+ const result = await service.create(prompt, scheduleInput, { kind })
143
+ return result.message
150
144
  }
package/src/format.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { formatDuration } from './parsing.js'
2
- import type { ScheduleSpec } from './types.js'
2
+ import type { ScheduleSpec, TaskKind } from './types.js'
3
3
 
4
- /** Format ScheduleSpec to readable string */
5
- export function formatSchedule(spec: ScheduleSpec): string {
4
+ /** Format ScheduleSpec to readable string. kind 区分 once/recurring(once 显示 'once in X' 而非误导性的 'every X')。 */
5
+ export function formatSchedule(spec: ScheduleSpec, kind?: TaskKind): string {
6
6
  if (spec.mode === 'interval') {
7
- return `every ${formatDuration(spec.intervalMs)}`
7
+ return kind === 'once'
8
+ ? `once in ${formatDuration(spec.intervalMs)}`
9
+ : `every ${formatDuration(spec.intervalMs)}`
8
10
  }
9
11
  return spec.cronExpression
10
12
  }