@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.
package/src/types.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  // ── 调度规格 ──
2
2
 
3
- export type ScheduleMode = 'cron' | 'interval'
4
-
5
3
  export type ScheduleSpec =
6
4
  | { mode: 'cron'; cronExpression: string }
7
5
  | { mode: 'interval'; intervalMs: number }
@@ -9,7 +7,7 @@ export type ScheduleSpec =
9
7
  // ── 任务 ──
10
8
 
11
9
  export type TaskKind = 'once' | 'recurring'
12
- export type TaskStatus = 'pending' | 'running' | 'success' | 'failed'
10
+ export type TaskStatus = 'success' | 'failed'
13
11
 
14
12
  export interface ScheduledTask {
15
13
  id: string // 8 位 hex,自动生成
@@ -25,8 +23,10 @@ export interface ScheduledTask {
25
23
  runCount: number
26
24
  lastRunAt?: number
27
25
  lastStatus?: TaskStatus
26
+ lastError?: string // 最近一次失败原因(cron 失效 / appendEntry 失败)
28
27
  history: ExecutionRecord[] // 最近 20 条
29
- pending?: boolean // 标记到期待 dispatch
28
+ ownerSessionFile?: string // append-only owner:记录任务创建时所属的 session JSONL,fork 重放时按此过滤非 owner 任务(gap1)
29
+ pending?: boolean // 运行时标记:到期待 dispatch(非持久化语义,勿与 TaskStatus 混淆)
30
30
  }
31
31
 
32
32
  export interface ExecutionRecord {
@@ -35,6 +35,45 @@ export interface ExecutionRecord {
35
35
  snippet?: string // agent 回复前 100 字
36
36
  }
37
37
 
38
+ // ── CustomEntry event sourcing(append-only 任务存储)──
39
+
40
+ /**
41
+ * upsert op 携带的全量任务快照(不含 ownerSessionFile / pending)。
42
+ * ownerSessionFile 放在 op 顶层(见 SchedulerEntryOp.upsert),
43
+ * pending 是运行时标记不持久化。其余字段与 ScheduledTask 对齐。
44
+ */
45
+ export interface TaskSnapshot {
46
+ id: string
47
+ name: string
48
+ prompt: string
49
+ kind: TaskKind
50
+ schedule: ScheduleSpec
51
+ enabled: boolean
52
+ force: boolean
53
+ createdAt: number
54
+ nextRunAt: number
55
+ expiresAt?: number
56
+ runCount: number
57
+ lastRunAt?: number
58
+ lastStatus?: TaskStatus
59
+ lastError?: string
60
+ history: ExecutionRecord[]
61
+ }
62
+
63
+ /**
64
+ * pi.appendEntry('pi-scheduler:task', SchedulerEntryOp) 写入 session JSONL 的 op 联合类型。
65
+ *
66
+ * advance.status 固定为 'success'(CL8):对齐现有 TaskStatus='success'|'failed',
67
+ * 而非继承的 DM-SCHEDULER-OP 词表 'ok'。唯一 emit 值——按 CL7,advance 仅在 dispatch
68
+ * 成功(nextRunAt 实际推进)时 append,失败 dispatch 不 append(transient 失败重试语义)。
69
+ * fold 的 `task.lastStatus = entry.status` 直接赋值合法('success' 属 TaskStatus)。
70
+ */
71
+ export type SchedulerEntryOp =
72
+ | { op: 'upsert'; taskId: string; ownerSessionFile: string; task: TaskSnapshot }
73
+ | { op: 'advance'; taskId: string; nextRunAt: number; at: number; status: 'success' }
74
+ | { op: 'toggle'; taskId: string; enabled: boolean; nextRunAt?: number }
75
+ | { op: 'delete'; taskId: string }
76
+
38
77
  // ── 持久化 ──
39
78
 
40
79
  export interface SchedulerStore {
@@ -46,7 +85,6 @@ export interface SchedulerStore {
46
85
 
47
86
  export interface ParseScheduleResult {
48
87
  spec: ScheduleSpec
49
- note?: string
50
88
  }
51
89
 
52
90
  // ── 添加选项 ──
@@ -1,161 +0,0 @@
1
- import * as fs from 'node:fs'
2
-
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
-
5
- import { createStore, getStorePath } from '../store.js'
6
- import type { SchedulerStore } from '../types.js'
7
-
8
- // Mock fs 模块
9
- vi.mock('node:fs', () => ({
10
- existsSync: vi.fn(),
11
- readFileSync: vi.fn(),
12
- writeFileSync: vi.fn(),
13
- mkdirSync: vi.fn(),
14
- }))
15
-
16
- describe('getStorePath', () => {
17
- it('returns path under ~/.pi/agent/scheduler/', () => {
18
- const p = getStorePath('/Users/test/project')
19
- expect(p).toContain('.pi')
20
- expect(p).toContain('agent')
21
- expect(p).toContain('scheduler')
22
- expect(p).toContain('scheduler.json')
23
- })
24
-
25
- it('generates different paths for different cwds', () => {
26
- const p1 = getStorePath('/Users/test/project1')
27
- const p2 = getStorePath('/Users/test/project2')
28
- expect(p1).not.toBe(p2)
29
- })
30
- })
31
-
32
- describe('createStore', () => {
33
- const mockCwd = '/test/project'
34
- let store: ReturnType<typeof createStore>
35
-
36
- beforeEach(() => {
37
- vi.clearAllMocks()
38
- store = createStore(mockCwd)
39
- })
40
-
41
- afterEach(() => {
42
- vi.restoreAllMocks()
43
- })
44
-
45
- describe('load', () => {
46
- it('returns empty store when file does not exist', () => {
47
- vi.mocked(fs.existsSync).mockReturnValue(false)
48
- const result = store.load()
49
- expect(result).toEqual({ version: 1, tasks: [] })
50
- })
51
-
52
- it('parses valid JSON file', () => {
53
- const mockStore: SchedulerStore = {
54
- version: 1,
55
- tasks: [{
56
- id: 'abc12345',
57
- name: 'test',
58
- prompt: 'test prompt',
59
- kind: 'recurring',
60
- schedule: { mode: 'interval', intervalMs: 60000 },
61
- enabled: true,
62
- force: false,
63
- createdAt: Date.now(),
64
- nextRunAt: Date.now() + 60000,
65
- runCount: 0,
66
- history: [],
67
- }],
68
- }
69
- vi.mocked(fs.existsSync).mockReturnValue(true)
70
- vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockStore))
71
- const result = store.load()
72
- expect(result.tasks).toHaveLength(1)
73
- expect(result.tasks[0]!.id).toBe('abc12345')
74
- })
75
-
76
- it('returns empty store on corrupted JSON', () => {
77
- vi.mocked(fs.existsSync).mockReturnValue(true)
78
- vi.mocked(fs.readFileSync).mockReturnValue('invalid json{{{')
79
- const result = store.load()
80
- expect(result).toEqual({ version: 1, tasks: [] })
81
- })
82
- })
83
-
84
- describe('persistSync', () => {
85
- it('writes store to file', () => {
86
- const mockStore: SchedulerStore = { version: 1, tasks: [] }
87
- store.persistSync(mockStore)
88
- expect(fs.writeFileSync).toHaveBeenCalledWith(
89
- expect.stringContaining('scheduler.json'),
90
- expect.any(String),
91
- 'utf-8',
92
- )
93
- })
94
-
95
- it('removes expired tasks', () => {
96
- const now = Date.now()
97
- const mockStore: SchedulerStore = {
98
- version: 1,
99
- tasks: [
100
- {
101
- id: 'expired',
102
- name: 'expired task',
103
- prompt: 'test',
104
- kind: 'recurring',
105
- schedule: { mode: 'interval', intervalMs: 60000 },
106
- enabled: true,
107
- force: false,
108
- createdAt: now - 100000,
109
- nextRunAt: now - 50000,
110
- expiresAt: now - 10000,
111
- runCount: 0,
112
- history: [],
113
- },
114
- {
115
- id: 'active',
116
- name: 'active task',
117
- prompt: 'test',
118
- kind: 'recurring',
119
- schedule: { mode: 'interval', intervalMs: 60000 },
120
- enabled: true,
121
- force: false,
122
- createdAt: now,
123
- nextRunAt: now + 60000,
124
- runCount: 0,
125
- history: [],
126
- },
127
- ],
128
- }
129
- store.persistSync(mockStore)
130
- const written = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string)
131
- expect(written.tasks).toHaveLength(1)
132
- expect(written.tasks[0].id).toBe('active')
133
- })
134
-
135
- it('trims history to 20 entries', () => {
136
- const history = Array.from({ length: 25 }, (_, i) => ({
137
- at: i * 1000,
138
- status: 'success' as const,
139
- }))
140
- const mockStore: SchedulerStore = {
141
- version: 1,
142
- tasks: [{
143
- id: 'task1',
144
- name: 'task',
145
- prompt: 'test',
146
- kind: 'recurring',
147
- schedule: { mode: 'interval', intervalMs: 60000 },
148
- enabled: true,
149
- force: false,
150
- createdAt: 0,
151
- nextRunAt: 60000,
152
- runCount: 25,
153
- history,
154
- }],
155
- }
156
- store.persistSync(mockStore)
157
- const written = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0]![1] as string)
158
- expect(written.tasks[0].history).toHaveLength(20)
159
- })
160
- })
161
- })
package/src/store.ts DELETED
@@ -1,117 +0,0 @@
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
- }