@zhushanwen/pi-scheduler 0.4.2 → 0.4.3
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 +3 -3
- package/src/format.ts +29 -13
- package/src/parsing.ts +26 -13
- package/src/runtime.ts +7 -5
- package/src/widget.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"vitest.config.ts"
|
|
30
30
|
],
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
33
33
|
"croner": "^9.0.0",
|
|
34
34
|
"typebox": "*"
|
|
35
35
|
},
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@xyz-agent/session-delivery": "0.3.0",
|
|
49
|
-
"@zhushanwen/pi-extension-logger": "0.3.
|
|
49
|
+
"@zhushanwen/pi-extension-logger": "0.3.1"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "vitest run",
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { formatDuration } from './parsing.js'
|
|
1
|
+
import { formatDuration, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND } from './parsing.js'
|
|
2
2
|
import type { ScheduleSpec, TaskKind } from './types.js'
|
|
3
3
|
|
|
4
|
+
/** 相对时间显示的"现在"判定窗口(±5s 内视为 now)。 */
|
|
5
|
+
const NOW_THRESHOLD_MS = 5000
|
|
6
|
+
/** 省略号 "..." 的字符数(truncate 截断预留宽度)。 */
|
|
7
|
+
const ELLIPSIS_LENGTH = 3
|
|
8
|
+
|
|
4
9
|
/** Format ScheduleSpec to readable string. kind 区分 once/recurring(once 显示 'once in X' 而非误导性的 'every X')。 */
|
|
5
10
|
export function formatSchedule(spec: ScheduleSpec, kind?: TaskKind): string {
|
|
6
11
|
if (spec.mode === 'interval') {
|
|
@@ -25,14 +30,14 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
25
30
|
const diff = timestamp - currentTime
|
|
26
31
|
|
|
27
32
|
// 5秒内视为"现在"
|
|
28
|
-
if (Math.abs(diff) <
|
|
33
|
+
if (Math.abs(diff) < NOW_THRESHOLD_MS) return 'now'
|
|
29
34
|
|
|
30
35
|
const absDiff = Math.abs(diff)
|
|
31
36
|
const units: [string, number][] = [
|
|
32
|
-
['d',
|
|
33
|
-
['h',
|
|
34
|
-
['m',
|
|
35
|
-
['s',
|
|
37
|
+
['d', MS_PER_DAY],
|
|
38
|
+
['h', MS_PER_HOUR],
|
|
39
|
+
['m', MS_PER_MINUTE],
|
|
40
|
+
['s', MS_PER_SECOND],
|
|
36
41
|
]
|
|
37
42
|
|
|
38
43
|
let formatted = ''
|
|
@@ -45,7 +50,7 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
if (!formatted) {
|
|
48
|
-
formatted = `${Math.round(absDiff /
|
|
53
|
+
formatted = `${Math.round(absDiff / MS_PER_SECOND)}s`
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
return diff > 0 ? `in ${formatted}` : `${formatted} ago`
|
|
@@ -56,24 +61,35 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
56
61
|
*/
|
|
57
62
|
export function truncate(text: string, maxLen: number): string {
|
|
58
63
|
if (text.length <= maxLen) return text
|
|
59
|
-
if (maxLen <=
|
|
60
|
-
return text.slice(0, maxLen -
|
|
64
|
+
if (maxLen <= ELLIPSIS_LENGTH) return text.slice(0, maxLen)
|
|
65
|
+
return text.slice(0, maxLen - ELLIPSIS_LENGTH) + '...'
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/** 生成任务 ID 的随机字节数(8 位 hex = 4 字节)。 */
|
|
69
|
+
const TASK_ID_RANDOM_BYTES = 4
|
|
70
|
+
const HEX_RADIX = 16
|
|
71
|
+
/** 每字节展开的 hex 字符数(padStart 宽度)。 */
|
|
72
|
+
const HEX_CHARS_PER_BYTE = 2
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* 生成任务 ID:8 位 hex。
|
|
65
76
|
*/
|
|
66
77
|
export function generateTaskId(): string {
|
|
67
|
-
const bytes = new Uint8Array(
|
|
78
|
+
const bytes = new Uint8Array(TASK_ID_RANDOM_BYTES)
|
|
68
79
|
crypto.getRandomValues(bytes)
|
|
69
|
-
return Array.from(bytes, b => b.toString(
|
|
80
|
+
return Array.from(bytes, b => b.toString(HEX_RADIX).padStart(HEX_CHARS_PER_BYTE, '0')).join('')
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/** autoName 任务名最大长度。 */
|
|
84
|
+
const AUTO_NAME_MAX_LENGTH = 30
|
|
85
|
+
/** 截断后保留的长度(预留省略号宽度)。 */
|
|
86
|
+
const AUTO_NAME_KEEP_LENGTH = AUTO_NAME_MAX_LENGTH - ELLIPSIS_LENGTH
|
|
87
|
+
|
|
72
88
|
/**
|
|
73
89
|
* 从 prompt 自动生成任务名称:取前 30 字。
|
|
74
90
|
*/
|
|
75
91
|
export function autoName(prompt: string): string {
|
|
76
92
|
const trimmed = prompt.trim()
|
|
77
|
-
if (trimmed.length <=
|
|
78
|
-
return trimmed.slice(0,
|
|
93
|
+
if (trimmed.length <= AUTO_NAME_MAX_LENGTH) return trimmed
|
|
94
|
+
return trimmed.slice(0, AUTO_NAME_KEEP_LENGTH) + '...'
|
|
79
95
|
}
|
package/src/parsing.ts
CHANGED
|
@@ -2,13 +2,19 @@ import type { ParseScheduleResult, ScheduleSpec } from './types.js'
|
|
|
2
2
|
|
|
3
3
|
// ── Duration 解析 ──
|
|
4
4
|
|
|
5
|
+
// 时间单位毫秒数(包内共享:format.ts / runtime.ts 复用)
|
|
6
|
+
export const MS_PER_DAY = 86_400_000
|
|
7
|
+
export const MS_PER_HOUR = 3_600_000
|
|
8
|
+
export const MS_PER_MINUTE = 60_000
|
|
9
|
+
export const MS_PER_SECOND = 1000
|
|
10
|
+
|
|
5
11
|
const DURATION_RE = /^(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i
|
|
6
12
|
|
|
7
13
|
const DURATION_MULTIPLIERS: Record<string, number> = {
|
|
8
|
-
s:
|
|
9
|
-
m:
|
|
10
|
-
h:
|
|
11
|
-
d:
|
|
14
|
+
s: MS_PER_SECOND, sec: MS_PER_SECOND, second: MS_PER_SECOND, seconds: MS_PER_SECOND,
|
|
15
|
+
m: MS_PER_MINUTE, min: MS_PER_MINUTE, minute: MS_PER_MINUTE, minutes: MS_PER_MINUTE,
|
|
16
|
+
h: MS_PER_HOUR, hr: MS_PER_HOUR, hour: MS_PER_HOUR, hours: MS_PER_HOUR,
|
|
17
|
+
d: MS_PER_DAY, day: MS_PER_DAY, days: MS_PER_DAY,
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
/**
|
|
@@ -34,10 +40,10 @@ export function formatDuration(ms: number): string {
|
|
|
34
40
|
if (ms <= 0) return '0s'
|
|
35
41
|
|
|
36
42
|
const units: [string, number][] = [
|
|
37
|
-
['d',
|
|
38
|
-
['h',
|
|
39
|
-
['m',
|
|
40
|
-
['s',
|
|
43
|
+
['d', MS_PER_DAY],
|
|
44
|
+
['h', MS_PER_HOUR],
|
|
45
|
+
['m', MS_PER_MINUTE],
|
|
46
|
+
['s', MS_PER_SECOND],
|
|
41
47
|
]
|
|
42
48
|
|
|
43
49
|
for (const [suffix, divisor] of units) {
|
|
@@ -47,11 +53,18 @@ export function formatDuration(ms: number): string {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// 兜底:用秒表示
|
|
50
|
-
return `${Math.round(ms /
|
|
56
|
+
return `${Math.round(ms / MS_PER_SECOND)}s`
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
// ── Cron 解析 ──
|
|
54
60
|
|
|
61
|
+
/** cron 标准字段数(分 时 日 月 周)。 */
|
|
62
|
+
const CRON_FIELD_COUNT = 5
|
|
63
|
+
/** 带秒字段的标准 cron 字段数(秒 分 时 日 月 周)。 */
|
|
64
|
+
const CRON_FIELD_COUNT_WITH_SECONDS = 6
|
|
65
|
+
/** computeNextCronRuns / computeNextRuns 默认返回的未来执行时间数。 */
|
|
66
|
+
const DEFAULT_NEXT_RUNS_COUNT = 5
|
|
67
|
+
|
|
55
68
|
let cronerModule: typeof import('croner') | null | undefined
|
|
56
69
|
|
|
57
70
|
async function getCroner(): Promise<typeof import('croner') | null> {
|
|
@@ -76,12 +89,12 @@ export function normalizeCronExpression(input: string): { expression: string; no
|
|
|
76
89
|
const parts = trimmed.split(/\s+/)
|
|
77
90
|
|
|
78
91
|
// 6 字段原样返回
|
|
79
|
-
if (parts.length ===
|
|
92
|
+
if (parts.length === CRON_FIELD_COUNT_WITH_SECONDS) {
|
|
80
93
|
return { expression: trimmed }
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
// 5 字段补秒字段
|
|
84
|
-
if (parts.length ===
|
|
97
|
+
if (parts.length === CRON_FIELD_COUNT) {
|
|
85
98
|
return {
|
|
86
99
|
expression: `0 ${trimmed}`,
|
|
87
100
|
note: 'Auto-prepended seconds field (0)',
|
|
@@ -121,7 +134,7 @@ export async function computeNextCronRunAt(
|
|
|
121
134
|
export async function computeNextCronRuns(
|
|
122
135
|
expression: string,
|
|
123
136
|
from?: number,
|
|
124
|
-
count =
|
|
137
|
+
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
125
138
|
): Promise<number[]> {
|
|
126
139
|
const croner = await getCroner()
|
|
127
140
|
if (!croner) return []
|
|
@@ -215,7 +228,7 @@ export async function computeNextRunAt(
|
|
|
215
228
|
export async function computeNextRuns(
|
|
216
229
|
spec: ScheduleSpec,
|
|
217
230
|
from?: number,
|
|
218
|
-
count =
|
|
231
|
+
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
219
232
|
): Promise<number[]> {
|
|
220
233
|
if (spec.mode === 'interval') {
|
|
221
234
|
const start = from ?? Date.now()
|
package/src/runtime.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-deliver
|
|
|
5
5
|
|
|
6
6
|
import type { SchedulerBackend } from './backend.js'
|
|
7
7
|
import { autoName, generateTaskId } from './format.js'
|
|
8
|
-
import { computeNextRunAt, parseDuration } from './parsing.js'
|
|
8
|
+
import { computeNextRunAt, MS_PER_DAY, MS_PER_MINUTE, parseDuration } from './parsing.js'
|
|
9
9
|
import type {
|
|
10
10
|
AddOptions,
|
|
11
11
|
ScheduledTask,
|
|
@@ -17,11 +17,13 @@ import type {
|
|
|
17
17
|
const logger = getLogger('scheduler')
|
|
18
18
|
|
|
19
19
|
const MAX_TASKS = 50
|
|
20
|
-
// 入队防重标记 TTL
|
|
21
|
-
const
|
|
20
|
+
// 入队防重标记 TTL 分钟数(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
|
|
21
|
+
const QUEUE_DEDUPE_TTL_MINUTES = 10
|
|
22
|
+
const QUEUE_DEDUPE_TTL_MS = QUEUE_DEDUPE_TTL_MINUTES * MS_PER_MINUTE
|
|
22
23
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
23
24
|
const TICK_INTERVAL_MS = 30_000
|
|
24
|
-
const
|
|
25
|
+
const DEFAULT_EXPIRY_DAYS = 7
|
|
26
|
+
const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
|
|
25
27
|
const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
|
|
26
28
|
// pi ExtensionRunner 在 session 替换后访问 stale ctx 时抛出的错误文案片段。
|
|
27
29
|
// 兜底通道(防御纵深):G1 模块级代际检测(isCtxStale)为主判,覆盖同模块环境内的 session
|
|
@@ -466,7 +468,7 @@ export class SchedulerRuntime {
|
|
|
466
468
|
}
|
|
467
469
|
|
|
468
470
|
private hasDispatchCapacity(now: number): boolean {
|
|
469
|
-
const oneMinuteAgo = now -
|
|
471
|
+
const oneMinuteAgo = now - MS_PER_MINUTE
|
|
470
472
|
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|
|
471
473
|
return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
|
|
472
474
|
}
|
package/src/widget.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { formatRelativeTime, truncate } from './format.js'
|
|
2
2
|
import type { ScheduledTask } from './types.js'
|
|
3
3
|
|
|
4
|
+
/** widget 中任务名的最大显示宽度(列)。 */
|
|
5
|
+
const WIDGET_NAME_MAX_WIDTH = 20
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* 渲染 TUI status bar widget(string[],配合 SDK setWidget 第一重载)。
|
|
6
9
|
* 格式:[scheduler] 3 scheduled · check-build in 4m · 1 overdue
|
|
@@ -23,7 +26,7 @@ export function renderSchedulerWidget(tasks: ScheduledTask[]): string[] {
|
|
|
23
26
|
|
|
24
27
|
if (upcoming.length > 0) {
|
|
25
28
|
const next = upcoming[0]!
|
|
26
|
-
parts.push(`${truncate(next.name,
|
|
29
|
+
parts.push(`${truncate(next.name, WIDGET_NAME_MAX_WIDTH)} ${formatRelativeTime(next.nextRunAt)}`)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (overdue.length > 0) {
|