@zhushanwen/pi-scheduler 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -51,10 +51,10 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "croner": "^9.0.0",
54
- "@zhushanwen/extension-protocol": "0.13.0",
54
+ "@zhushanwen/extension-protocol": "0.14.0",
55
55
  "@zhushanwen/pi-ext-guards": "0.4.1",
56
56
  "@zhushanwen/pi-extension-logger": "0.6.1",
57
- "@zhushanwen/pi-llm-shared": "0.9.0"
57
+ "@zhushanwen/pi-llm-shared": "0.10.0"
58
58
  },
59
59
  "scripts": {
60
60
  "test": "vitest run",
package/src/format.ts CHANGED
@@ -1,6 +1,19 @@
1
+ // 时间格式化器(r4 M1 locale 版):formatSchedule / formatRelativeTime 是 locale 必填的
2
+ // 本地实现(UiLocale 双语输出,i18n.ts / widget / service / commands 消费);fix 轨 locale
3
+ // 化与 U2 protocol 下沉(无 locale 面,服务 taiji plugin 管理面)暂双轨并存,收敛待后续
4
+ // 把 locale 版提升进 @zhushanwen/extension-protocol。formatDuration / MS_PER_* 单源
5
+ // @zhushanwen/extension-protocol(re-export 保持既有 import 路径)。
1
6
  import { formatDuration, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND } from './parsing.js'
2
7
  import type { ScheduleSpec, TaskKind } from './types.js'
3
8
 
9
+ export {
10
+ MS_PER_DAY,
11
+ MS_PER_HOUR,
12
+ MS_PER_MINUTE,
13
+ MS_PER_SECOND,
14
+ formatDuration,
15
+ } from '@zhushanwen/extension-protocol'
16
+
4
17
  /**
5
18
  * 界面语言(L2 extension 侧文案语言,经 `<dataDir>/ui-preferences.json` 通道获取)。
6
19
  * 定义在 format.ts:i18n.ts 依赖本模块的格式化器,类型反向定义会成环——i18n.ts 再导出。
package/src/replay.ts CHANGED
@@ -1,194 +1,23 @@
1
- import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
1
+ // 折叠器单源于 @zhushanwen/extension-protocol(U2 折叠器下沉)。包内实现零 pi 依赖
2
+ // (logger 以 ReplayFoldOptions.warn 注入),本文件是扩展侧薄 wrapper:注入共享 logger
3
+ // 保持降级日志行为不变,并保持既有 import 路径与二参签名不变(backend.ts 零改动)。
4
+
5
+ import {
6
+ replayFoldEntries as replayFoldEntriesShared,
7
+ type ScheduledTask,
8
+ type SchedulerEntryLike,
9
+ } from '@zhushanwen/extension-protocol'
2
10
  import { getLogger } from '@zhushanwen/pi-extension-logger'
3
11
 
4
- import { appendExecutionRecord, snapshotToTask, TASK_ENTRY_TYPE } from './types.js'
5
- import type { ScheduledTask, SchedulerEntryOp } from './types.js'
6
-
7
12
  const logger = getLogger('scheduler')
8
13
 
9
- /**
10
- * CustomEntry 的最小可识别形状(duck-typed,不依赖 @earendil-works/pi-coding-agent 的
11
- * 具体 SessionEntry 类型)。与 pending-notifications/state.ts、goal/ports.ts 同款:
12
- * 纯函数可独立单测,调用方传入真实 SessionEntry[] 时按结构兼容。
13
- */
14
- export interface SchedulerEntryLike {
15
- type: string
16
- customType?: string
17
- data?: unknown
18
- }
19
-
20
- /**
21
- * 从 CustomEntry 序列折叠恢复 per-task 末态(event sourcing)。
22
- *
23
- * session_start 重放入口:PiSchedulerBackend.loadTasks() 委托本函数,把当前 session 的
24
- * TASK_ENTRY_TYPE custom entries 折叠成运行时任务 Map。
25
- *
26
- * 两步:
27
- * ① 全量折叠(foldEntries)——遍历 customType===TASK_ENTRY_TYPE 的 entries,按 op 类型折叠:
28
- * - upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)
29
- * - advance → nextRunAt=新值 / lastRunAt=at / runCount++ / history.push(裁20) / lastStatus=status(gap2)
30
- * - toggle → enabled=新值
31
- * - delete → 从 Map 移除(后续同 taskId 的 advance/toggle 对不存在 taskId 为 no-op,安全)
32
- * advance/toggle/delete 对不存在的 taskId 为 no-op,因此 fork 继承的完整序列必须先全量折叠,
33
- * 不能在处理 upsert 时按 owner 跳过(否则后续无 owner 字段的 op 会作用于「不存在的 taskId」丢失)。
34
- * ② 折叠完成后按 task.ownerSessionFile !== currentSessionFile 整体过滤(gap1,filterNonOwnerTasks):
35
- * 移除 fork 继承的非 owner 任务——fork 出的 session B 继承了 owner=A 的 upsert+advance 序列,
36
- * 但 B 不应拥有 A 的任务,重放后整体过滤掉。
37
- *
38
- * append-only 时序保证 nextRunAt 不回退(D1):advance entry 按写入顺序折叠,自然取到最后推进值。
39
- *
40
- * getEntries 解析/迭代异常 → logger.warn + 返回空 Map(gap4):降级为无任务而非崩溃 session_start。
41
- */
42
14
  export function replayFoldEntries(
43
15
  entries: Iterable<SchedulerEntryLike>,
44
16
  currentSessionFile: string | undefined,
45
17
  ): Map<string, ScheduledTask> {
46
- try {
47
- const tasks = new Map<string, ScheduledTask>()
48
- foldEntries(entries, tasks)
49
- filterNonOwnerTasks(tasks, currentSessionFile)
50
- return tasks
51
- } catch (err) {
52
- // gap4:session JSONL 损坏 / 迭代器抛错时降级为无任务,不让 session_start 崩溃。
53
- logger.warn('replayFoldEntries failed', { error: toErrorMessage(err) })
54
- return new Map()
55
- }
56
- }
57
-
58
- /** 步骤① 全量折叠:跳过非本扩展 entry 与守卫不过的损坏 entry;单条折叠抛错只跳过该条(MF-2)。 */
59
- function foldEntries(entries: Iterable<SchedulerEntryLike>, tasks: Map<string, ScheduledTask>): void {
60
- for (const entry of entries) {
61
- if (entry.type !== 'custom' || entry.customType !== TASK_ENTRY_TYPE) continue
62
- if (!isSchedulerEntryOp(entry.data)) continue
63
- const op = entry.data // SchedulerEntryOp(守卫 isSchedulerEntryOp 收窄)
64
-
65
- try {
66
- applyOp(tasks, op)
67
- } catch (err) {
68
- // MF-2:守卫已按变体校验必填字段,但嵌套数据损坏(如 upsert task.history 非数组 →
69
- // snapshotToTask 的 .map 抛)仍可能抛——逐条 try/catch 只跳过该条,
70
- // 不让外层整体 catch 把全部任务清成空 Map(一条损坏 entry 不得清空全部任务)
71
- logger.warn('skipping corrupted scheduler entry', { error: toErrorMessage(err) })
72
- continue
73
- }
74
- }
75
- }
76
-
77
- /** 按 op 判别值分发到对应折叠函数;无 default——未知 op 静默跳过(守卫已排除,理论不可达)。 */
78
- function applyOp(tasks: Map<string, ScheduledTask>, op: SchedulerEntryOp): void {
79
- switch (op.op) {
80
- case 'upsert':
81
- applyUpsert(tasks, op)
82
- break
83
- case 'advance':
84
- applyAdvance(tasks, op)
85
- break
86
- case 'toggle':
87
- applyToggle(tasks, op)
88
- break
89
- case 'delete':
90
- applyDelete(tasks, op)
91
- break
92
- }
93
- }
94
-
95
- /** upsert → 用快照重建任务(ownerSessionFile 来自 op 顶层)。 */
96
- function applyUpsert(
97
- tasks: Map<string, ScheduledTask>,
98
- op: Extract<SchedulerEntryOp, { op: 'upsert' }>,
99
- ): void {
100
- tasks.set(op.taskId, { ...snapshotToTask(op.task), ownerSessionFile: op.ownerSessionFile })
18
+ return replayFoldEntriesShared(entries, currentSessionFile, {
19
+ warn: (message, context) => logger.warn(message, context),
20
+ })
101
21
  }
102
22
 
103
- /** advance → 推进运行末态;对不存在的 taskId 为 no-op(fork 场景安全)。 */
104
- function applyAdvance(
105
- tasks: Map<string, ScheduledTask>,
106
- op: Extract<SchedulerEntryOp, { op: 'advance' }>,
107
- ): void {
108
- const task = tasks.get(op.taskId)
109
- if (!task) return // no-op for unknown taskId(fork 场景安全)
110
- task.nextRunAt = op.nextRunAt
111
- task.lastRunAt = op.at
112
- task.runCount += 1
113
- appendExecutionRecord(task, op.at, op.status)
114
- task.lastStatus = op.status // gap2:advance 必须恢复 lastStatus(不只 nextRunAt/lastRunAt/runCount/history)
115
- }
116
-
117
- /** toggle → enabled=新值;携带 nextRunAt 时一并应用(P1,防快照回退到旧过期值)。 */
118
- function applyToggle(
119
- tasks: Map<string, ScheduledTask>,
120
- op: Extract<SchedulerEntryOp, { op: 'toggle' }>,
121
- ): void {
122
- const task = tasks.get(op.taskId)
123
- if (!task) return
124
- task.enabled = op.enabled
125
- // P1:enable 重算到未来的 nextRunAt 随 toggle op 持久化;重放时应用,
126
- // 防 upsert 快照回退到旧过期值导致首个 tick 立即触发
127
- if (op.nextRunAt !== undefined) task.nextRunAt = op.nextRunAt
128
- }
129
-
130
- /** delete → 从 Map 移除;taskId 不存在时 no-op。 */
131
- function applyDelete(tasks: Map<string, ScheduledTask>, op: Extract<SchedulerEntryOp, { op: 'delete' }>): void {
132
- tasks.delete(op.taskId)
133
- }
134
-
135
- /**
136
- * 步骤② fork owner 过滤(gap1):折叠完成后整体移除非 owner 任务。
137
- * currentSessionFile 为 undefined(--no-session 模式)时,所有带 ownerSessionFile 的任务都被移除
138
- * (无 session 文件可归属,等同空 session)——该模式下 addTask 用 '' 兜底 owner,此处比较仍一致。
139
- */
140
- function filterNonOwnerTasks(tasks: Map<string, ScheduledTask>, currentSessionFile: string | undefined): void {
141
- for (const [id, task] of tasks) {
142
- if (task.ownerSessionFile !== currentSessionFile) {
143
- tasks.delete(id)
144
- }
145
- }
146
- }
147
-
148
- /**
149
- * 类型守卫:判断 entry.data 是否为合法 SchedulerEntryOp——按 op 变体校验必填字段,
150
- * 不只校验 op 判别值(MF-2 修复)。仅按判别值收窄时,损坏 entry `{op:'upsert'}`(缺 task)
151
- * 会通过守卫后在 snapshotToTask(undefined) 抛 TypeError,被外层整体 catch 捕获 → 返回空 Map
152
- * → 一条损坏 entry 清空全部任务(违反「只忽略该条」的设计意图)。
153
- *
154
- * 变体必填校验:
155
- * upsert → taskId: string + task: 非 null 对象(嵌套字段损坏由重放循环内逐条 try/catch 兜底跳过)
156
- * advance → taskId: string + nextRunAt: number + at: number + status: 'success'
157
- * toggle → taskId: string + enabled: boolean(nextRunAt?: number 可选——合法 toggle 可缺省,不能误拒)
158
- * delete → taskId: string
159
- * 校验不过 → 返回 false,该条被跳过(不触发整体 catch)。
160
- *
161
- * 替代 `as { op?: unknown }` 结构断言(taste/no-unsafe-cast):守卫函数内做真实校验,
162
- * Record<string, unknown> 配合 typeof/值校验,不是「无校验断言」。
163
- */
164
- function isSchedulerEntryOp(data: unknown): data is SchedulerEntryOp {
165
- if (!data || typeof data !== 'object') return false
166
- const record = data as Record<string, unknown>
167
- const op = record.op
168
- if (op === 'upsert') {
169
- return (
170
- typeof record.taskId === 'string' &&
171
- typeof record.task === 'object' &&
172
- record.task !== null
173
- )
174
- }
175
- if (op === 'advance') {
176
- return (
177
- typeof record.taskId === 'string' &&
178
- typeof record.nextRunAt === 'number' &&
179
- typeof record.at === 'number' &&
180
- record.status === 'success'
181
- )
182
- }
183
- if (op === 'toggle') {
184
- return (
185
- typeof record.taskId === 'string' &&
186
- typeof record.enabled === 'boolean' &&
187
- (record.nextRunAt === undefined || typeof record.nextRunAt === 'number')
188
- )
189
- }
190
- if (op === 'delete') {
191
- return typeof record.taskId === 'string'
192
- }
193
- return false
194
- }
23
+ export type { SchedulerEntryLike } from '@zhushanwen/extension-protocol'
package/src/types.ts CHANGED
@@ -1,103 +1,28 @@
1
- import type { ProviderConfig } from '@earendil-works/pi-coding-agent'
2
-
3
- // ── 调度规格 ──
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 = '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
- model?: string // 任务执行模型(scoped model id,provider/model);undefined = 跟随会话当前模型
21
- enabled: boolean
22
- createdAt: number
23
- nextRunAt: number
24
- expiresAt?: number // undefined = 永不过期
25
- runCount: number
26
- lastRunAt?: number
27
- lastStatus?: TaskStatus
28
- lastError?: string // 最近一次失败原因(cron 失效 / appendEntry 失败)
29
- history: ExecutionRecord[] // 最近 HISTORY_LIMIT 条
30
- ownerSessionFile?: string // append-only owner:记录任务创建时所属的 session JSONL,fork 重放时按此过滤非 owner 任务(gap1)
31
- pending?: boolean // 运行时标记:到期待 dispatch(非持久化语义,勿与 TaskStatus 混淆)
32
- }
33
-
34
- export interface ExecutionRecord {
35
- at: number
36
- status: TaskStatus
37
- }
38
-
39
- /**
40
- * history 裁剪上限(ext-simplify-08 L5 单点):runtime 侧 dispatch 累积
41
- * (onDispatchSuccess)与 replay 侧 advance 折叠共用同一上限——
42
- * 双处各持字面量会漂移(曾 runtime.ts / replay.ts 各写一份 20),收敛到 types.ts 单点导出。
43
- */
44
- export const HISTORY_LIMIT = 20
45
-
46
- /**
47
- * 追加执行记录并按 HISTORY_LIMIT 裁剪(ext-simplify-17 B4 单点):runtime 侧 dispatch
48
- * 累积与 replay 侧 advance 折叠共用的 push+trim——三处内联时与上限字面量同源存在
49
- * 「漏改一处」漂移风险,随上限一并收敛。
50
- */
51
- export function appendExecutionRecord(task: ScheduledTask, at: number, status: TaskStatus): void {
52
- task.history.push({ at, status })
53
- if (task.history.length > HISTORY_LIMIT) task.history.shift()
54
- }
55
-
56
- // ── CustomEntry event sourcing(append-only 任务存储)──
1
+ // entry 契约与折叠器单源于 @zhushanwen/extension-protocol(U2 折叠器下沉:扩展与 taiji plugin
2
+ // 同源消费,杜绝双实现漂移)。本文件保持既有 import 路径不变(re-export),仅保留
3
+ // 扩展侧写面私有物(toTaskSnapshot / 旧 store 形状 / 工具参数 / ack 机制类型)。
57
4
 
58
- /**
59
- * pi.appendEntry 写入的 custom entry customType 标识(ext-simplify-17 B1 单点):
60
- * backend.appendEntry 写入 / replay.foldEntries 过滤 / importer 旧 store 导入三处共用——
61
- * 各持字面量时「改一处漏两处」会使写入与重放识别脱节(写入新标识、重放不认,任务静默丢失)。
62
- */
63
- export const TASK_ENTRY_TYPE = 'pi-scheduler:task'
64
-
65
- /**
66
- * upsert op 携带的全量任务快照(不含 ownerSessionFile / pending)。
67
- * ownerSessionFile 放在 op 顶层(见 SchedulerEntryOp.upsert),
68
- * pending 是运行时标记不持久化。其余字段与 ScheduledTask 对齐。
69
- */
70
- export interface TaskSnapshot {
71
- id: string
72
- name: string
73
- prompt: string
74
- kind: TaskKind
75
- schedule: ScheduleSpec
76
- model?: string
77
- enabled: boolean
78
- createdAt: number
79
- nextRunAt: number
80
- expiresAt?: number
81
- runCount: number
82
- lastRunAt?: number
83
- lastStatus?: TaskStatus
84
- lastError?: string
85
- history: ExecutionRecord[]
86
- }
5
+ // ack 覆写 config 类型同源自 pi(文件尾 SchedulerCurrentModel / SchedulerProviderOverride 消费)
6
+ import type { ProviderConfig } from '@earendil-works/pi-coding-agent'
87
7
 
88
- /**
89
- * pi.appendEntry(TASK_ENTRY_TYPE, SchedulerEntryOp) 写入 session JSONL 的 op 联合类型。
90
- *
91
- * advance.status 固定为 'success'(CL8):对齐现有 TaskStatus='success'|'failed',
92
- * 而非继承的 DM-SCHEDULER-OP 词表 'ok'。唯一 emit 值——按 CL7,advance 仅在 dispatch
93
- * 成功(nextRunAt 实际推进)时 append,失败 dispatch 不 append(transient 失败重试语义)。
94
- * fold 的 `task.lastStatus = entry.status` 直接赋值合法('success' 属 TaskStatus)。
95
- */
96
- export type SchedulerEntryOp =
97
- | { op: 'upsert'; taskId: string; ownerSessionFile: string; task: TaskSnapshot }
98
- | { op: 'advance'; taskId: string; nextRunAt: number; at: number; status: 'success' }
99
- | { op: 'toggle'; taskId: string; enabled: boolean; nextRunAt?: number }
100
- | { op: 'delete'; taskId: string }
8
+ export {
9
+ appendExecutionRecord,
10
+ HISTORY_LIMIT,
11
+ snapshotToTask,
12
+ TASK_ENTRY_TYPE,
13
+ } from '@zhushanwen/extension-protocol'
14
+ export type {
15
+ ExecutionRecord,
16
+ ScheduleSpec,
17
+ ScheduledTask,
18
+ SchedulerEntryOp,
19
+ TaskKind,
20
+ TaskSnapshot,
21
+ TaskStatus,
22
+ } from '@zhushanwen/extension-protocol'
23
+
24
+ // 本地保留面(写侧 helper / 旧 store 形状 / 工具参数)签名引用的类型——re-export 不引入作用域,需显式 import
25
+ import type { ScheduledTask, TaskKind, TaskSnapshot } from '@zhushanwen/extension-protocol'
101
26
 
102
27
  /**
103
28
  * ScheduledTask → TaskSnapshot(ext-simplify-17 B2 canonical,解构剥离式):剥离
@@ -114,31 +39,6 @@ export function toTaskSnapshot(task: ScheduledTask): TaskSnapshot {
114
39
  return { ...rest, history: history.slice() }
115
40
  }
116
41
 
117
- /**
118
- * TaskSnapshot → ScheduledTask(replay 侧重建):pending 不持久化故不恢复,
119
- * ownerSessionFile 由调用方(replay applyUpsert)从 op 顶层补回。history 逐项深拷贝——
120
- * 避免恢复出的运行时 task 与快照共享数组/元素引用(task 后续被 mutate 时不污染快照)。
121
- */
122
- export function snapshotToTask(snapshot: TaskSnapshot): ScheduledTask {
123
- return {
124
- id: snapshot.id,
125
- name: snapshot.name,
126
- prompt: snapshot.prompt,
127
- kind: snapshot.kind,
128
- schedule: snapshot.schedule,
129
- model: snapshot.model,
130
- enabled: snapshot.enabled,
131
- createdAt: snapshot.createdAt,
132
- nextRunAt: snapshot.nextRunAt,
133
- expiresAt: snapshot.expiresAt,
134
- runCount: snapshot.runCount,
135
- lastRunAt: snapshot.lastRunAt,
136
- lastStatus: snapshot.lastStatus,
137
- lastError: snapshot.lastError,
138
- history: snapshot.history.map(h => ({ ...h })),
139
- }
140
- }
141
-
142
42
  // ── 持久化 ──
143
43
 
144
44
  export interface SchedulerStore {