my-opencode-toolkit 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +112 -2
- package/index.ts +3 -0
- package/package.json +65 -4
- package/src/goal/command.ts +84 -0
- package/src/goal/continuation.ts +211 -0
- package/src/goal/controller.ts +107 -0
- package/src/goal/error.ts +94 -0
- package/src/goal/filter.ts +21 -0
- package/src/goal/index.ts +43 -0
- package/src/goal/parse-command.ts +31 -0
- package/src/goal/prompts.ts +84 -0
- package/src/goal/session-role.ts +37 -0
- package/src/goal/store.ts +91 -0
- package/src/goal/tools.ts +131 -0
- package/src/goal/types.ts +75 -0
- package/src/i18n/en.ts +61 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +57 -0
- package/src/i18n/zh.ts +61 -0
- package/src/index.ts +150 -0
- package/src/notification/format.ts +42 -0
- package/src/notification/index.ts +165 -0
- package/src/notification/send.ts +104 -0
- package/src/scheduling/command.ts +181 -0
- package/src/scheduling/continuation.ts +164 -0
- package/src/scheduling/cron.ts +309 -0
- package/src/scheduling/filter.ts +25 -0
- package/src/scheduling/index.ts +68 -0
- package/src/scheduling/loops.ts +235 -0
- package/src/scheduling/store.ts +228 -0
- package/src/scheduling/tools.ts +183 -0
- package/src/scheduling/types.ts +54 -0
- package/src/shared/events.ts +44 -0
- package/src/shared/format.ts +16 -0
- package/src/shared/json.ts +44 -0
- package/src/shared/keys.ts +26 -0
- package/src/shared/messages.ts +17 -0
- package/src/shared/rpc.ts +214 -0
- package/src/shared/scan.ts +21 -0
- package/src/shared/settings-store.ts +28 -0
- package/src/shared/settings.ts +133 -0
- package/src/shared/types.ts +4 -0
- package/src/stats.ts +215 -0
- package/src/tools/ping.ts +40 -0
- package/src/tui/index.tsx +274 -0
- package/tui.ts +3 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { Plugin } from "@opencode/plugin"
|
|
5
|
+
|
|
6
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
7
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
8
|
+
import type { LoopScheduler } from "./loops.ts"
|
|
9
|
+
import type { SchedulingStore } from "./store.ts"
|
|
10
|
+
import { cronToHuman } from "./cron.ts"
|
|
11
|
+
|
|
12
|
+
/** 内置维护提示词(没有 `loop.md` 时的默认)。 */
|
|
13
|
+
export const DEFAULT_LOOP_MAINTENANCE_PROMPT =
|
|
14
|
+
"Continue unfinished work in this session. Re-read AGENTS.md and progress files, pick the next concrete step, " +
|
|
15
|
+
"and keep going. Run cleanup passes when appropriate."
|
|
16
|
+
|
|
17
|
+
/** `/loop` 解析结果。 */
|
|
18
|
+
export type LoopCommand =
|
|
19
|
+
| { readonly kind: "start"; readonly prompt: string; readonly intervalSeconds?: number }
|
|
20
|
+
| { readonly kind: "stop" }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 输入:`5m` / `30s` / `2h` / `1d` 形式。
|
|
24
|
+
* 输出:秒数(秒向上取整到分钟,最小 60),非法返回 undefined。
|
|
25
|
+
*/
|
|
26
|
+
export function parseIntervalSeconds(value: string): number | undefined {
|
|
27
|
+
const match = /^(\d+)([smhd])$/.exec(value)
|
|
28
|
+
if (match === null) return undefined
|
|
29
|
+
const amount = Number.parseInt(match[1] ?? "", 10)
|
|
30
|
+
const unit = match[2]
|
|
31
|
+
if (!Number.isFinite(amount) || amount <= 0) return undefined
|
|
32
|
+
switch (unit) {
|
|
33
|
+
case "s":
|
|
34
|
+
return Math.max(60, Math.ceil(amount / 60) * 60)
|
|
35
|
+
case "m":
|
|
36
|
+
return amount * 60
|
|
37
|
+
case "h":
|
|
38
|
+
return amount * 3600
|
|
39
|
+
case "d":
|
|
40
|
+
return amount * 86400
|
|
41
|
+
default:
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 输入:`/loop` 参数。
|
|
48
|
+
* 输出:停止,或带可选固定间隔的启动参数。
|
|
49
|
+
* 预期:首个 token 是合法间隔则剥离,剩余为 prompt;`stop` 单独处理。
|
|
50
|
+
*/
|
|
51
|
+
export function parseLoopCommand(rawArgs: string): LoopCommand {
|
|
52
|
+
const trimmed = rawArgs.trim()
|
|
53
|
+
if (trimmed.toLowerCase() === "stop") return { kind: "stop" }
|
|
54
|
+
const parts = trimmed.split(/\s+/)
|
|
55
|
+
const first = parts[0]
|
|
56
|
+
if (first === undefined || first === "") return { kind: "start", prompt: "" }
|
|
57
|
+
|
|
58
|
+
const intervalSeconds = parseIntervalSeconds(first)
|
|
59
|
+
if (intervalSeconds === undefined) return { kind: "start", prompt: trimmed }
|
|
60
|
+
return { kind: "start", prompt: parts.slice(1).join(" ").trim(), intervalSeconds }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 输入:项目目录;输出:维护提示词。预期:`loop.md` 非空时优先。 */
|
|
64
|
+
async function loadMaintenancePrompt(directory: string | undefined): Promise<string> {
|
|
65
|
+
if (directory === undefined) return DEFAULT_LOOP_MAINTENANCE_PROMPT
|
|
66
|
+
try {
|
|
67
|
+
const raw = (await readFile(join(directory, "loop.md"), "utf8")).trim()
|
|
68
|
+
return raw.length > 0 ? raw : DEFAULT_LOOP_MAINTENANCE_PROMPT
|
|
69
|
+
} catch {
|
|
70
|
+
return DEFAULT_LOOP_MAINTENANCE_PROMPT
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 输入:`/cron` 参数。
|
|
76
|
+
* 输出:`{ expr, prompt }` 或错误文本。
|
|
77
|
+
* 预期:前 5 个 token 是 cron 表达式,剩余为 prompt。
|
|
78
|
+
*/
|
|
79
|
+
function splitCron(rest: string): { expr: string; prompt: string } | null {
|
|
80
|
+
const parts = rest.trim().split(/\s+/)
|
|
81
|
+
if (parts.length < 6) return null
|
|
82
|
+
return { expr: parts.slice(0, 5).join(" "), prompt: parts.slice(5).join(" ") }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 输入:插件上下文、任务存储、循环调度器、会话角色。
|
|
87
|
+
* 输出:注册 `/loop` 与 `/cron` 命令。
|
|
88
|
+
* 预期:命令自身不进对话流;执行结果作为普通 prompt 发回当前会话。
|
|
89
|
+
*/
|
|
90
|
+
export async function registerSchedulingCommands(
|
|
91
|
+
ctx: Plugin.Context,
|
|
92
|
+
store: SchedulingStore,
|
|
93
|
+
scheduler: LoopScheduler,
|
|
94
|
+
roles: SessionRoles,
|
|
95
|
+
): Promise<{ dispose(): Promise<void> }> {
|
|
96
|
+
/** 输入:会话 ID;输出:不可用原因,可用时为 null。 */
|
|
97
|
+
const unavailable = async (sessionID: string): Promise<string | null> => {
|
|
98
|
+
const settings = await loadSettings(ctx.storage)
|
|
99
|
+
if (!settings.enabled || !settings.scheduling.enabled) return "Error: the scheduling feature is disabled in settings."
|
|
100
|
+
if (await roles.isSubagent(sessionID)) return "Error: scheduling commands are not available to subagents."
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const registration = await ctx.command.transform((editor) => {
|
|
105
|
+
editor.add({
|
|
106
|
+
name: "loop",
|
|
107
|
+
description: "Start or stop a recurring prompt loop in this session",
|
|
108
|
+
execute: async (input) => {
|
|
109
|
+
const blocked = await unavailable(input.sessionID)
|
|
110
|
+
const parsed = parseLoopCommand(input.prompt.text)
|
|
111
|
+
const text = await (async (): Promise<string> => {
|
|
112
|
+
if (blocked !== null) return blocked
|
|
113
|
+
if (parsed.kind === "stop") {
|
|
114
|
+
const count = scheduler.stopAllLoops(input.sessionID)
|
|
115
|
+
return count === 0 ? "No active loop." : `Stopped ${String(count)} loop(s).`
|
|
116
|
+
}
|
|
117
|
+
const prompt = parsed.prompt.length > 0 ? parsed.prompt : await loadMaintenancePrompt(ctx.location.directory)
|
|
118
|
+
const loop = scheduler.startLoop(input.sessionID, prompt, parsed.intervalSeconds)
|
|
119
|
+
return loop.mode === "fixed"
|
|
120
|
+
? `Loop ${loop.id} started: every ${String(loop.intervalSeconds ?? 0)}s — ${prompt}`
|
|
121
|
+
: `Loop ${loop.id} started (dynamic): ${prompt}`
|
|
122
|
+
})()
|
|
123
|
+
await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
editor.add({
|
|
128
|
+
name: "cron",
|
|
129
|
+
description: "Create, list or delete scheduled prompts for this session",
|
|
130
|
+
execute: async (input) => {
|
|
131
|
+
const blocked = await unavailable(input.sessionID)
|
|
132
|
+
const raw = input.prompt.text.trim()
|
|
133
|
+
const text = await (async (): Promise<string> => {
|
|
134
|
+
if (blocked !== null) return blocked
|
|
135
|
+
|
|
136
|
+
if (raw === "" || raw.toLowerCase() === "list") {
|
|
137
|
+
const jobs = store.listJobs(input.sessionID)
|
|
138
|
+
if (jobs.length === 0) return "No scheduled jobs."
|
|
139
|
+
return jobs
|
|
140
|
+
.map((job) => {
|
|
141
|
+
const kind = job.recurring === true ? "recurring" : "one-shot"
|
|
142
|
+
return `${job.id} — ${cronToHuman(job.cron)} (${kind}): ${job.prompt}`
|
|
143
|
+
})
|
|
144
|
+
.join("\n")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const deleteMatch = /^delete\s+(\S+)$/i.exec(raw)
|
|
148
|
+
if (deleteMatch !== null) {
|
|
149
|
+
const id = deleteMatch[1] ?? ""
|
|
150
|
+
const job = store.getJob(id)
|
|
151
|
+
if (job === undefined) return `Error: No scheduled job with id '${id}'`
|
|
152
|
+
if (job.sessionID !== input.sessionID) return `Error: Cannot delete cron job '${id}': owned by another session`
|
|
153
|
+
await store.removeJobs([id])
|
|
154
|
+
return `Cancelled job ${id}.`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const once = /^once\s+(.+)$/is.exec(raw)
|
|
158
|
+
const body = once === null ? raw : (once[1] ?? "")
|
|
159
|
+
const split = splitCron(body)
|
|
160
|
+
if (split === null) return "Usage: /cron <expr> <prompt> | /cron once <expr> <prompt> | /cron list | /cron delete <id>"
|
|
161
|
+
|
|
162
|
+
const result = await store.addJob({
|
|
163
|
+
sessionID: input.sessionID,
|
|
164
|
+
cron: split.expr,
|
|
165
|
+
prompt: split.prompt,
|
|
166
|
+
recurring: once === null,
|
|
167
|
+
durable: true,
|
|
168
|
+
})
|
|
169
|
+
if (!result.ok) return `Error: ${result.error}`
|
|
170
|
+
const job = result.job
|
|
171
|
+
return job.recurring === true
|
|
172
|
+
? `Scheduled recurring job ${job.id} (${cronToHuman(job.cron)}): ${job.prompt}`
|
|
173
|
+
: `Scheduled one-shot task ${job.id} (${cronToHuman(job.cron)}): ${job.prompt}`
|
|
174
|
+
})()
|
|
175
|
+
await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
await ctx.command.reload()
|
|
180
|
+
return registration
|
|
181
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import { readEventData } from "../shared/events.ts"
|
|
4
|
+
import { lastMessageIsUserInput } from "../shared/messages.ts"
|
|
5
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
6
|
+
import type { SchedulingStore } from "./store.ts"
|
|
7
|
+
import { isRecurringTaskAged } from "./store.ts"
|
|
8
|
+
import type { ScheduledJob } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
/** 注入消息的 metadata 标记。 */
|
|
11
|
+
const CRON_MARKER = "cronContinuation"
|
|
12
|
+
|
|
13
|
+
/** 定时任务触发控制器。 */
|
|
14
|
+
export type SchedulingContinuation = {
|
|
15
|
+
/** 输入:会话事件;输出:无。预期:idle 兜底触发,删除会话清任务。 */
|
|
16
|
+
handle(event: { readonly type: string; readonly data: unknown }): Promise<void>
|
|
17
|
+
/** 输入:无;输出:无。预期:给所有未来任务武装 timer。 */
|
|
18
|
+
armTimers(): void
|
|
19
|
+
/** 输入:无;输出:无。预期:清掉全部 timer。 */
|
|
20
|
+
dispose(): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 输入:时间。
|
|
25
|
+
* 输出:`Nov 3 2:30pm` 形式的英文时间。
|
|
26
|
+
* 预期:只用于注入文本,固定 en-US 以免跟随系统语言变化。
|
|
27
|
+
*/
|
|
28
|
+
function formatFireTime(date: Date): string {
|
|
29
|
+
return date
|
|
30
|
+
.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
|
31
|
+
.replace(/,? at |, /, " ")
|
|
32
|
+
.replace(/ ([AP]M)/, (_, ampm: string) => ampm.toLowerCase())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 输入:任务 + 触发时间;输出:注入文本。 */
|
|
36
|
+
function buildFirePrompt(job: ScheduledJob, at: Date): string {
|
|
37
|
+
return `Running scheduled task (${formatFireTime(at)})\n\n${job.prompt}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 输入:插件上下文、存储、会话角色、让路回调、启用判断。
|
|
42
|
+
* 输出:定时任务触发控制器。
|
|
43
|
+
* 预期:到点先无条件重排/删除,再尽力注入;子代理与让路时不注入。
|
|
44
|
+
*/
|
|
45
|
+
export function createSchedulingContinuation(args: {
|
|
46
|
+
readonly ctx: Plugin.Context
|
|
47
|
+
readonly store: SchedulingStore
|
|
48
|
+
readonly roles: SessionRoles
|
|
49
|
+
readonly shouldDefer: (sessionID: string) => Promise<boolean>
|
|
50
|
+
readonly enabled: () => Promise<boolean>
|
|
51
|
+
readonly now?: () => number
|
|
52
|
+
}): SchedulingContinuation {
|
|
53
|
+
const { ctx, store, roles, shouldDefer, enabled } = args
|
|
54
|
+
const now = args.now ?? Date.now
|
|
55
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
56
|
+
const inFlight = new Set<string>()
|
|
57
|
+
const pending = new Set<string>()
|
|
58
|
+
|
|
59
|
+
/** 输入:会话 + 文本;输出:是否注入成功。预期:并发、上一条未完成或末条是用户输入时跳过。 */
|
|
60
|
+
const inject = async (sessionID: string, text: string): Promise<boolean> => {
|
|
61
|
+
if (inFlight.has(sessionID) || pending.has(sessionID)) return false
|
|
62
|
+
if (await roles.isSubagent(sessionID)) return false
|
|
63
|
+
if (await lastMessageIsUserInput(ctx, sessionID, CRON_MARKER)) return false
|
|
64
|
+
inFlight.add(sessionID)
|
|
65
|
+
try {
|
|
66
|
+
await ctx.session.prompt({ sessionID, text, delivery: "steer", metadata: { [CRON_MARKER]: true } })
|
|
67
|
+
pending.add(sessionID)
|
|
68
|
+
return true
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
} finally {
|
|
72
|
+
inFlight.delete(sessionID)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 输入:无;输出:无。预期:每个未来任务一个 timer,已有 timer 的不重复武装。 */
|
|
77
|
+
const armTimers = (): void => {
|
|
78
|
+
const at = now()
|
|
79
|
+
for (const job of store.listJobs()) {
|
|
80
|
+
if (job.nextRunAt === undefined || timers.has(job.id)) continue
|
|
81
|
+
const delay = job.nextRunAt - at
|
|
82
|
+
if (delay <= 0) continue
|
|
83
|
+
const handle = setTimeout(() => {
|
|
84
|
+
timers.delete(job.id)
|
|
85
|
+
const fresh = store.getJob(job.id)
|
|
86
|
+
if (fresh !== undefined) void handleCronFire(fresh)
|
|
87
|
+
}, delay)
|
|
88
|
+
handle.unref()
|
|
89
|
+
timers.set(job.id, handle)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 输入:任务;输出:无。预期:先重排/删除,再判断是否注入。 */
|
|
94
|
+
const handleCronFire = async (job: ScheduledJob): Promise<void> => {
|
|
95
|
+
const at = now()
|
|
96
|
+
const aged = isRecurringTaskAged(job, at)
|
|
97
|
+
if (aged || job.recurring !== true) {
|
|
98
|
+
await store.removeJobs([job.id])
|
|
99
|
+
} else {
|
|
100
|
+
await store.markFired([job.id], at)
|
|
101
|
+
}
|
|
102
|
+
armTimers()
|
|
103
|
+
|
|
104
|
+
if (!(await enabled())) return
|
|
105
|
+
if (await shouldDefer(job.sessionID)) return
|
|
106
|
+
await inject(job.sessionID, buildFirePrompt(job, new Date(at)))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 输入:会话 ID;输出:无。预期:处理该会话已到点的任务,否则补武装 timer。 */
|
|
110
|
+
const handleIdle = async (sessionID: string): Promise<void> => {
|
|
111
|
+
if (!(await enabled())) return
|
|
112
|
+
const at = now()
|
|
113
|
+
const due = store
|
|
114
|
+
.listJobs(sessionID)
|
|
115
|
+
.filter((job) => job.nextRunAt !== undefined && job.nextRunAt <= at)
|
|
116
|
+
.sort((a, b) => (a.nextRunAt ?? Number.POSITIVE_INFINITY) - (b.nextRunAt ?? Number.POSITIVE_INFINITY))
|
|
117
|
+
const job = due[0]
|
|
118
|
+
if (job !== undefined) {
|
|
119
|
+
await handleCronFire(job)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
armTimers()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async handle(event) {
|
|
127
|
+
const data = readEventData(event)
|
|
128
|
+
const sessionID = data?.["sessionID"]
|
|
129
|
+
if (typeof sessionID !== "string" || sessionID.length === 0) return
|
|
130
|
+
|
|
131
|
+
if (event.type === "session.idle") {
|
|
132
|
+
await handleIdle(sessionID)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (event.type === "session.deleted") {
|
|
136
|
+
const ids = store.listJobs(sessionID).map((job) => job.id)
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
const timer = timers.get(id)
|
|
139
|
+
if (timer !== undefined) {
|
|
140
|
+
clearTimeout(timer)
|
|
141
|
+
timers.delete(id)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await store.removeJobs(ids)
|
|
145
|
+
inFlight.delete(sessionID)
|
|
146
|
+
pending.delete(sessionID)
|
|
147
|
+
roles.forget(sessionID)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
|
|
151
|
+
pending.delete(sessionID)
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
armTimers,
|
|
156
|
+
|
|
157
|
+
dispose() {
|
|
158
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
159
|
+
timers.clear()
|
|
160
|
+
inFlight.clear()
|
|
161
|
+
pending.clear()
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/** 展开后的 5 个 cron 字段。 */
|
|
2
|
+
export type CronFields = {
|
|
3
|
+
minute: number[]
|
|
4
|
+
hour: number[]
|
|
5
|
+
dayOfMonth: number[]
|
|
6
|
+
month: number[]
|
|
7
|
+
dayOfWeek: number[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type FieldRange = { min: number; max: number }
|
|
11
|
+
|
|
12
|
+
const FIELD_RANGES: readonly FieldRange[] = [
|
|
13
|
+
{ min: 0, max: 59 }, // minute
|
|
14
|
+
{ min: 0, max: 23 }, // hour
|
|
15
|
+
{ min: 1, max: 31 }, // dayOfMonth
|
|
16
|
+
{ min: 1, max: 12 }, // month
|
|
17
|
+
{ min: 0, max: 6 }, // dayOfWeek(7 也接受为周日)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 输入:单个 cron 字段 + 取值范围。
|
|
22
|
+
* 输出:升序匹配值数组,非法返回 `null`。
|
|
23
|
+
* 预期:支持通配、步进(`*` 后跟斜杠与数字)、单值、范围、范围步进、逗号列表;dayOfWeek 的 7 归一成 0。
|
|
24
|
+
*/
|
|
25
|
+
function expandField(field: string, range: FieldRange): number[] | null {
|
|
26
|
+
const { min, max } = range
|
|
27
|
+
const out = new Set<number>()
|
|
28
|
+
|
|
29
|
+
for (const part of field.split(",")) {
|
|
30
|
+
const stepMatch = /^\*(?:\/(\d+))?$/.exec(part)
|
|
31
|
+
if (stepMatch !== null) {
|
|
32
|
+
const step = stepMatch[1] === undefined ? 1 : Number.parseInt(stepMatch[1], 10)
|
|
33
|
+
if (step < 1) return null
|
|
34
|
+
for (let i = min; i <= max; i += step) out.add(i)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rangeMatch = /^(\d+)-(\d+)(?:\/(\d+))?$/.exec(part)
|
|
39
|
+
if (rangeMatch !== null) {
|
|
40
|
+
const lo = Number.parseInt(rangeMatch[1] ?? "", 10)
|
|
41
|
+
const hi = Number.parseInt(rangeMatch[2] ?? "", 10)
|
|
42
|
+
const step = rangeMatch[3] === undefined ? 1 : Number.parseInt(rangeMatch[3], 10)
|
|
43
|
+
// dayOfWeek: 范围里 7 作为周日别名(5-7 → [5,6,0])
|
|
44
|
+
const isDow = min === 0 && max === 6
|
|
45
|
+
const effMax = isDow ? 7 : max
|
|
46
|
+
if (lo > hi || step < 1 || lo < min || hi > effMax) return null
|
|
47
|
+
for (let i = lo; i <= hi; i += step) out.add(isDow && i === 7 ? 0 : i)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (/^\d+$/.test(part)) {
|
|
52
|
+
let n = Number.parseInt(part, 10)
|
|
53
|
+
if (min === 0 && max === 6 && n === 7) n = 0
|
|
54
|
+
if (n < min || n > max) return null
|
|
55
|
+
out.add(n)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (out.size === 0) return null
|
|
63
|
+
return Array.from(out).sort((a, b) => a - b)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 输入:5 字段 cron(本地时区,`minute hour dayOfMonth month dayOfWeek`)。
|
|
68
|
+
* 输出:展开后的字段,非法返回 `null`。
|
|
69
|
+
*/
|
|
70
|
+
export function parseCronExpression(expr: string): CronFields | null {
|
|
71
|
+
const parts = expr.trim().split(/\s+/)
|
|
72
|
+
if (parts.length !== 5) return null
|
|
73
|
+
|
|
74
|
+
const expanded: number[][] = []
|
|
75
|
+
for (const [index, part] of parts.entries()) {
|
|
76
|
+
const range = FIELD_RANGES[index]
|
|
77
|
+
if (range === undefined) return null
|
|
78
|
+
const result = expandField(part, range)
|
|
79
|
+
if (result === null) return null
|
|
80
|
+
expanded.push(result)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = expanded
|
|
84
|
+
if (
|
|
85
|
+
minute === undefined ||
|
|
86
|
+
hour === undefined ||
|
|
87
|
+
dayOfMonth === undefined ||
|
|
88
|
+
month === undefined ||
|
|
89
|
+
dayOfWeek === undefined
|
|
90
|
+
) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
return { minute, hour, dayOfMonth, month, dayOfWeek }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 输入:展开后的字段 + 起点时间。
|
|
98
|
+
* 输出:严格晚于起点、第一个匹配的本地时刻,一年内无匹配返回 `null`。
|
|
99
|
+
* 预期:dayOfMonth 与 dayOfWeek 都受限时任一匹配即可(OR 语义)。
|
|
100
|
+
*/
|
|
101
|
+
export function computeNextCronRun(fields: CronFields, from: Date): Date | null {
|
|
102
|
+
const minuteSet = new Set(fields.minute)
|
|
103
|
+
const hourSet = new Set(fields.hour)
|
|
104
|
+
const domSet = new Set(fields.dayOfMonth)
|
|
105
|
+
const monthSet = new Set(fields.month)
|
|
106
|
+
const dowSet = new Set(fields.dayOfWeek)
|
|
107
|
+
|
|
108
|
+
const domWild = fields.dayOfMonth.length === 31
|
|
109
|
+
const dowWild = fields.dayOfWeek.length === 7
|
|
110
|
+
|
|
111
|
+
const t = new Date(from.getTime())
|
|
112
|
+
t.setSeconds(0, 0)
|
|
113
|
+
t.setMinutes(t.getMinutes() + 1)
|
|
114
|
+
|
|
115
|
+
const maxIter = 366 * 24 * 60
|
|
116
|
+
for (let i = 0; i < maxIter; i += 1) {
|
|
117
|
+
if (!monthSet.has(t.getMonth() + 1)) {
|
|
118
|
+
t.setMonth(t.getMonth() + 1, 1)
|
|
119
|
+
t.setHours(0, 0, 0, 0)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const dayMatches =
|
|
124
|
+
domWild && dowWild
|
|
125
|
+
? true
|
|
126
|
+
: domWild
|
|
127
|
+
? dowSet.has(t.getDay())
|
|
128
|
+
: dowWild
|
|
129
|
+
? domSet.has(t.getDate())
|
|
130
|
+
: domSet.has(t.getDate()) || dowSet.has(t.getDay())
|
|
131
|
+
|
|
132
|
+
if (!dayMatches) {
|
|
133
|
+
t.setDate(t.getDate() + 1)
|
|
134
|
+
t.setHours(0, 0, 0, 0)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!hourSet.has(t.getHours())) {
|
|
139
|
+
t.setHours(t.getHours() + 1, 0, 0, 0)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!minuteSet.has(t.getMinutes())) {
|
|
144
|
+
t.setMinutes(t.getMinutes() + 1)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return t
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 输入:cron 字符串 + 起点毫秒。
|
|
156
|
+
* 输出:下一次触发毫秒,非法或无匹配返回 `null`。
|
|
157
|
+
*/
|
|
158
|
+
export function nextCronRunMs(cron: string, fromMs: number): number | null {
|
|
159
|
+
const fields = parseCronExpression(cron)
|
|
160
|
+
if (fields === null) return null
|
|
161
|
+
return computeNextCronRun(fields, new Date(fromMs))?.getTime() ?? null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** cron 的人类可读描述(英文)。 */
|
|
165
|
+
export type CronHuman =
|
|
166
|
+
| { kind: "every_minute"; n: number }
|
|
167
|
+
| { kind: "every_hour"; minute: number }
|
|
168
|
+
| { kind: "every_n_hours"; n: number; minute: number }
|
|
169
|
+
| { kind: "daily"; hour: number; minute: number }
|
|
170
|
+
| { kind: "weekly"; day: number; hour: number; minute: number }
|
|
171
|
+
| { kind: "weekdays"; hour: number; minute: number }
|
|
172
|
+
| { kind: "raw"; text: string }
|
|
173
|
+
|
|
174
|
+
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 输入:cron 字符串。
|
|
178
|
+
* 输出:常见模式的结构化描述,无法识别时回退 `raw`。
|
|
179
|
+
*/
|
|
180
|
+
function parseCronHuman(cron: string): CronHuman {
|
|
181
|
+
const parts = cron.trim().split(/\s+/)
|
|
182
|
+
if (parts.length !== 5) return { kind: "raw", text: cron }
|
|
183
|
+
const [minute = "", hour = "", dayOfMonth = "", month = "", dayOfWeek = ""] = parts
|
|
184
|
+
|
|
185
|
+
const everyMin = /^\*\/(\d+)$/.exec(minute)
|
|
186
|
+
if (everyMin !== null && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
|
|
187
|
+
return { kind: "every_minute", n: Number.parseInt(everyMin[1] ?? "1", 10) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (/^\d+$/.test(minute) && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
|
|
191
|
+
return { kind: "every_hour", minute: Number.parseInt(minute, 10) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const everyHour = /^\*\/(\d+)$/.exec(hour)
|
|
195
|
+
if (/^\d+$/.test(minute) && everyHour !== null && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
|
|
196
|
+
return {
|
|
197
|
+
kind: "every_n_hours",
|
|
198
|
+
n: Number.parseInt(everyHour[1] ?? "1", 10),
|
|
199
|
+
minute: Number.parseInt(minute, 10),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return { kind: "raw", text: cron }
|
|
204
|
+
const m = Number.parseInt(minute, 10)
|
|
205
|
+
const h = Number.parseInt(hour, 10)
|
|
206
|
+
|
|
207
|
+
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") return { kind: "daily", hour: h, minute: m }
|
|
208
|
+
if (dayOfMonth === "*" && month === "*" && /^\d$/.test(dayOfWeek)) {
|
|
209
|
+
return { kind: "weekly", day: Number.parseInt(dayOfWeek, 10) % 7, hour: h, minute: m }
|
|
210
|
+
}
|
|
211
|
+
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "1-5") return { kind: "weekdays", hour: h, minute: m }
|
|
212
|
+
|
|
213
|
+
return { kind: "raw", text: cron }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 输入:小时 + 分钟;输出:`HH:MM`。预期:固定两位,便于工具输出。 */
|
|
217
|
+
function formatTime(hour: number, minute: number): string {
|
|
218
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 输入:cron 字符串。
|
|
223
|
+
* 输出:英文人类可读描述(给模型看的工具输出)。
|
|
224
|
+
*/
|
|
225
|
+
export function cronToHuman(cron: string): string {
|
|
226
|
+
const human = parseCronHuman(cron)
|
|
227
|
+
switch (human.kind) {
|
|
228
|
+
case "every_minute":
|
|
229
|
+
return human.n === 1 ? "every minute" : `every ${String(human.n)} minutes`
|
|
230
|
+
case "every_hour":
|
|
231
|
+
return human.minute === 0 ? "every hour" : `every hour at :${String(human.minute).padStart(2, "0")}`
|
|
232
|
+
case "every_n_hours":
|
|
233
|
+
return `every ${String(human.n)} hours at :${String(human.minute).padStart(2, "0")}`
|
|
234
|
+
case "daily":
|
|
235
|
+
return `daily at ${formatTime(human.hour, human.minute)}`
|
|
236
|
+
case "weekly":
|
|
237
|
+
return `weekly on ${DAY_NAMES[human.day] ?? String(human.day)} at ${formatTime(human.hour, human.minute)}`
|
|
238
|
+
case "weekdays":
|
|
239
|
+
return `weekdays at ${formatTime(human.hour, human.minute)}`
|
|
240
|
+
case "raw":
|
|
241
|
+
return human.text
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** jitter 参数。 */
|
|
246
|
+
export type CronJitterConfig = {
|
|
247
|
+
readonly recurringFrac: number
|
|
248
|
+
readonly recurringCapMs: number
|
|
249
|
+
readonly oneShotMaxMs: number
|
|
250
|
+
readonly oneShotFloorMs: number
|
|
251
|
+
readonly oneShotMinuteMod: number
|
|
252
|
+
readonly recurringMaxAgeMs: number
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig = {
|
|
256
|
+
recurringFrac: 0.1,
|
|
257
|
+
recurringCapMs: 15 * 60 * 1000,
|
|
258
|
+
oneShotMaxMs: 90 * 1000,
|
|
259
|
+
oneShotFloorMs: 0,
|
|
260
|
+
oneShotMinuteMod: 30,
|
|
261
|
+
recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 输入:任务 id。
|
|
266
|
+
* 输出:`[0,1)` 的稳定分数。
|
|
267
|
+
* 预期:非 hex id 回退 0(无抖动)。
|
|
268
|
+
*/
|
|
269
|
+
function jitterFrac(taskId: string): number {
|
|
270
|
+
const frac = Number.parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000
|
|
271
|
+
return Number.isFinite(frac) ? frac : 0
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 输入:cron + 起点 + 任务 id。
|
|
276
|
+
* 输出:带确定性正向延迟的下一次触发时刻(recurring 用)。
|
|
277
|
+
* 预期:延迟不超过间隔的 10%,且不超过 15 分钟;没有第二跳时不抖。
|
|
278
|
+
*/
|
|
279
|
+
export function jitteredNextCronRunMs(
|
|
280
|
+
cron: string,
|
|
281
|
+
fromMs: number,
|
|
282
|
+
taskId: string,
|
|
283
|
+
cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
|
|
284
|
+
): number | null {
|
|
285
|
+
const first = nextCronRunMs(cron, fromMs)
|
|
286
|
+
if (first === null) return null
|
|
287
|
+
const second = nextCronRunMs(cron, first)
|
|
288
|
+
if (second === null) return first
|
|
289
|
+
const jitter = Math.min(jitterFrac(taskId) * cfg.recurringFrac * (second - first), cfg.recurringCapMs)
|
|
290
|
+
return first + jitter
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 输入:cron + 起点 + 任务 id。
|
|
295
|
+
* 输出:带确定性提前量的下一次触发时刻(one-shot 用)。
|
|
296
|
+
* 预期:只在分钟命中 `oneShotMinuteMod` 时提前,且不早于起点。
|
|
297
|
+
*/
|
|
298
|
+
export function oneShotJitteredNextCronRunMs(
|
|
299
|
+
cron: string,
|
|
300
|
+
fromMs: number,
|
|
301
|
+
taskId: string,
|
|
302
|
+
cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
|
|
303
|
+
): number | null {
|
|
304
|
+
const first = nextCronRunMs(cron, fromMs)
|
|
305
|
+
if (first === null) return null
|
|
306
|
+
if (new Date(first).getMinutes() % cfg.oneShotMinuteMod !== 0) return first
|
|
307
|
+
const lead = cfg.oneShotFloorMs + jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs)
|
|
308
|
+
return Math.max(first - lead, fromMs)
|
|
309
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入:即将发给模型的工具表(会被原地修改)。
|
|
3
|
+
* 输出:实际删掉的工具名。
|
|
4
|
+
* 预期:只删 scheduling 工具,其余工具原样保留;不存在时跳过。
|
|
5
|
+
*/
|
|
6
|
+
export function hideSchedulingTools(tools: Record<string, unknown>): string[] {
|
|
7
|
+
const removed: string[] = []
|
|
8
|
+
if ("cron_create" in tools) {
|
|
9
|
+
delete tools["cron_create"]
|
|
10
|
+
removed.push("cron_create")
|
|
11
|
+
}
|
|
12
|
+
if ("cron_list" in tools) {
|
|
13
|
+
delete tools["cron_list"]
|
|
14
|
+
removed.push("cron_list")
|
|
15
|
+
}
|
|
16
|
+
if ("cron_delete" in tools) {
|
|
17
|
+
delete tools["cron_delete"]
|
|
18
|
+
removed.push("cron_delete")
|
|
19
|
+
}
|
|
20
|
+
if ("schedule_wakeup" in tools) {
|
|
21
|
+
delete tools["schedule_wakeup"]
|
|
22
|
+
removed.push("schedule_wakeup")
|
|
23
|
+
}
|
|
24
|
+
return removed
|
|
25
|
+
}
|