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,68 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
4
|
+
import type { PluginEvent } from "../shared/events.ts"
|
|
5
|
+
import type { ScannableStore } from "../shared/json.ts"
|
|
6
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
7
|
+
import type { Disposable } from "../shared/types.ts"
|
|
8
|
+
import { registerSchedulingCommands } from "./command.ts"
|
|
9
|
+
import { createSchedulingContinuation } from "./continuation.ts"
|
|
10
|
+
import type { LoopScheduler } from "./loops.ts"
|
|
11
|
+
import { createLoopScheduler } from "./loops.ts"
|
|
12
|
+
import type { SchedulingStore } from "./store.ts"
|
|
13
|
+
import { createSchedulingStore } from "./store.ts"
|
|
14
|
+
import { registerSchedulingTools } from "./tools.ts"
|
|
15
|
+
|
|
16
|
+
/** 定时任务功能句柄 + 面板数据源。 */
|
|
17
|
+
export type SchedulingHandle = Disposable & {
|
|
18
|
+
readonly jobs: SchedulingStore
|
|
19
|
+
readonly scheduler: LoopScheduler
|
|
20
|
+
/** 输入:插件事件;输出:无。预期:cron 触发与 loop 状态维护;由外层统一分发。 */
|
|
21
|
+
handle(event: PluginEvent): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 输入:插件上下文、存储、会话角色、让路回调。
|
|
26
|
+
* 输出:定时任务功能句柄。
|
|
27
|
+
* 预期:恢复 durable 任务、注册工具与命令、武装 timer;事件由调用方统一分发。
|
|
28
|
+
*/
|
|
29
|
+
export async function registerScheduling(
|
|
30
|
+
ctx: Plugin.Context,
|
|
31
|
+
store: ScannableStore,
|
|
32
|
+
roles: SessionRoles,
|
|
33
|
+
shouldDefer: (sessionID: string) => Promise<boolean>,
|
|
34
|
+
): Promise<SchedulingHandle> {
|
|
35
|
+
const jobs = createSchedulingStore(store)
|
|
36
|
+
await jobs.load()
|
|
37
|
+
|
|
38
|
+
const scheduler = createLoopScheduler({ ctx, roles, shouldDefer })
|
|
39
|
+
const tools = await registerSchedulingTools(ctx, jobs, scheduler, roles)
|
|
40
|
+
const commands = await registerSchedulingCommands(ctx, jobs, scheduler, roles)
|
|
41
|
+
|
|
42
|
+
const continuation = createSchedulingContinuation({
|
|
43
|
+
ctx,
|
|
44
|
+
store: jobs,
|
|
45
|
+
roles,
|
|
46
|
+
shouldDefer,
|
|
47
|
+
enabled: async () => {
|
|
48
|
+
const settings = await loadSettings(store)
|
|
49
|
+
return settings.enabled && settings.scheduling.enabled
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
continuation.armTimers()
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
jobs,
|
|
56
|
+
scheduler,
|
|
57
|
+
async handle(event) {
|
|
58
|
+
await continuation.handle(event)
|
|
59
|
+
await scheduler.handleEvent(event)
|
|
60
|
+
},
|
|
61
|
+
async dispose() {
|
|
62
|
+
continuation.dispose()
|
|
63
|
+
scheduler.dispose()
|
|
64
|
+
await tools.dispose()
|
|
65
|
+
await commands.dispose()
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import type { Plugin } from "@opencode/plugin"
|
|
4
|
+
|
|
5
|
+
import { readEventData } from "../shared/events.ts"
|
|
6
|
+
import { lastMessageIsUserInput } from "../shared/messages.ts"
|
|
7
|
+
import { DEFAULT_MAX_AGE_DAYS, type Loop } from "./types.ts"
|
|
8
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
9
|
+
|
|
10
|
+
/** 注入消息的 metadata 标记。 */
|
|
11
|
+
const LOOP_MARKER = "loopContinuation"
|
|
12
|
+
|
|
13
|
+
/** dynamic 模式没有 `schedule_wakeup` 时的兜底唤醒(20 分钟)。 */
|
|
14
|
+
const FALLBACK_WAKEUP_MS = 20 * 60 * 1000
|
|
15
|
+
const WAKEUP_MIN_S = 60
|
|
16
|
+
const WAKEUP_MAX_S = 3600
|
|
17
|
+
|
|
18
|
+
/** `schedule_wakeup` 的结果。 */
|
|
19
|
+
export type ScheduleWakeupResult =
|
|
20
|
+
| { readonly ok: true; readonly id: string; readonly fireAt: number }
|
|
21
|
+
| { readonly ok: false; readonly error: string }
|
|
22
|
+
|
|
23
|
+
/** 循环调度器。 */
|
|
24
|
+
export type LoopScheduler = {
|
|
25
|
+
/** 输入:会话 + prompt + 可选间隔;输出:新循环。预期:立即触发一次。 */
|
|
26
|
+
startLoop(sessionID: string, prompt: string, intervalSeconds?: number): Loop
|
|
27
|
+
/** 输入:会话 + 可选 id;输出:是否停止。 */
|
|
28
|
+
stopLoop(sessionID: string, id?: string): boolean
|
|
29
|
+
/** 输入:会话 + prompt;输出:是否停止。 */
|
|
30
|
+
stopLoopByPrompt(sessionID: string, prompt: string): boolean
|
|
31
|
+
/** 输入:会话;输出:停止数量。 */
|
|
32
|
+
stopAllLoops(sessionID: string): number
|
|
33
|
+
/** 输入:会话;输出:该会话的循环。 */
|
|
34
|
+
listLoops(sessionID: string): Loop[]
|
|
35
|
+
/** 输入:会话 + prompt + 延迟 + 原因;输出:唤醒结果。 */
|
|
36
|
+
scheduleWakeup(sessionID: string, prompt: string, delaySeconds: number, reason: string): ScheduleWakeupResult
|
|
37
|
+
/** 输入:会话事件;输出:无。预期:step 结束清 pending,删除会话清循环。 */
|
|
38
|
+
handleEvent(event: { readonly type: string; readonly data: unknown }): Promise<void>
|
|
39
|
+
/** 输入:会话;输出:无。预期:停掉该会话全部循环。 */
|
|
40
|
+
clearSession(sessionID: string): void
|
|
41
|
+
/** 输入:无;输出:无。预期:清掉所有定时器。 */
|
|
42
|
+
dispose(): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 输入:插件上下文、会话角色、是否让路回调。
|
|
47
|
+
* 输出:循环调度器。
|
|
48
|
+
* 预期:fixed 按间隔重复,dynamic 等 `schedule_wakeup`,都不给子代理注入。
|
|
49
|
+
*/
|
|
50
|
+
export function createLoopScheduler(args: {
|
|
51
|
+
readonly ctx: Plugin.Context
|
|
52
|
+
readonly roles: SessionRoles
|
|
53
|
+
readonly shouldDefer: (sessionID: string) => Promise<boolean>
|
|
54
|
+
}): LoopScheduler {
|
|
55
|
+
const { ctx, roles, shouldDefer } = args
|
|
56
|
+
const loops = new Map<string, Loop>()
|
|
57
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
58
|
+
const inFlight = new Set<string>()
|
|
59
|
+
const pending = new Set<string>()
|
|
60
|
+
|
|
61
|
+
/** 输入:会话 + prompt;输出:匹配的循环。预期:同一 prompt 只保留一个。 */
|
|
62
|
+
const findLoop = (sessionID: string, prompt: string): Loop | undefined =>
|
|
63
|
+
[...loops.values()].find((loop) => loop.sessionID === sessionID && loop.prompt === prompt)
|
|
64
|
+
|
|
65
|
+
/** 输入:会话 + 文本;输出:是否注入成功。预期:子代理、并发、上一条未完成、末条是用户输入时跳过。 */
|
|
66
|
+
const inject = async (sessionID: string, text: string): Promise<boolean> => {
|
|
67
|
+
if (inFlight.has(sessionID) || pending.has(sessionID)) return false
|
|
68
|
+
if (await roles.isSubagent(sessionID)) return false
|
|
69
|
+
if (await lastMessageIsUserInput(ctx, sessionID, LOOP_MARKER)) return false
|
|
70
|
+
inFlight.add(sessionID)
|
|
71
|
+
try {
|
|
72
|
+
await ctx.session.prompt({ sessionID, text, delivery: "steer", metadata: { [LOOP_MARKER]: true } })
|
|
73
|
+
pending.add(sessionID)
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
} finally {
|
|
78
|
+
inFlight.delete(sessionID)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 输入:循环;输出:无。预期:触发一次注入;过期则停掉。 */
|
|
83
|
+
const fireLoop = async (loop: Loop): Promise<void> => {
|
|
84
|
+
if (Date.now() >= loop.expiresAt) {
|
|
85
|
+
stopLoop(loop.sessionID, loop.id)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (await shouldDefer(loop.sessionID)) return
|
|
89
|
+
loop.fires += 1
|
|
90
|
+
await inject(loop.sessionID, loop.prompt)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 输入:循环 + 延迟毫秒;输出:无。预期:重设 timer,`unref` 不阻止进程退出。 */
|
|
94
|
+
const armLoop = (loop: Loop, delayMs: number): void => {
|
|
95
|
+
const existing = timers.get(loop.id)
|
|
96
|
+
if (existing !== undefined) clearTimeout(existing)
|
|
97
|
+
loop.nextFireAt = Date.now() + delayMs
|
|
98
|
+
const handle = setTimeout(() => {
|
|
99
|
+
timers.delete(loop.id)
|
|
100
|
+
void handleFire(loop)
|
|
101
|
+
}, Math.max(0, delayMs))
|
|
102
|
+
handle.unref()
|
|
103
|
+
timers.set(loop.id, handle)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 输入:循环;输出:无。预期:触发后按模式决定下一次。 */
|
|
107
|
+
const handleFire = async (loop: Loop): Promise<void> => {
|
|
108
|
+
const hadWakeup = loop.pendingWakeup
|
|
109
|
+
await fireLoop(loop)
|
|
110
|
+
if (!loops.has(loop.id)) return
|
|
111
|
+
|
|
112
|
+
if (loop.mode === "fixed" && loop.intervalSeconds !== undefined) {
|
|
113
|
+
armLoop(loop, loop.intervalSeconds * 1000)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (hadWakeup !== undefined) {
|
|
118
|
+
if (loop.pendingWakeup === hadWakeup) {
|
|
119
|
+
delete loop.pendingWakeup
|
|
120
|
+
armLoop(loop, FALLBACK_WAKEUP_MS)
|
|
121
|
+
}
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (loop.pendingWakeup !== undefined) return
|
|
125
|
+
stopLoop(loop.sessionID, loop.id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const startLoop = (sessionID: string, prompt: string, intervalSeconds?: number): Loop => {
|
|
129
|
+
const existing = findLoop(sessionID, prompt)
|
|
130
|
+
if (existing !== undefined) stopLoop(sessionID, existing.id)
|
|
131
|
+
|
|
132
|
+
const now = Date.now()
|
|
133
|
+
const loop: Loop = {
|
|
134
|
+
id: randomUUID().slice(0, 8),
|
|
135
|
+
sessionID,
|
|
136
|
+
prompt,
|
|
137
|
+
mode: intervalSeconds === undefined ? "dynamic" : "fixed",
|
|
138
|
+
...(intervalSeconds === undefined ? {} : { intervalSeconds }),
|
|
139
|
+
createdAt: now,
|
|
140
|
+
expiresAt: now + DEFAULT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000,
|
|
141
|
+
fires: 0,
|
|
142
|
+
}
|
|
143
|
+
loops.set(loop.id, loop)
|
|
144
|
+
void fireLoop(loop)
|
|
145
|
+
armLoop(loop, intervalSeconds === undefined ? FALLBACK_WAKEUP_MS : intervalSeconds * 1000)
|
|
146
|
+
return loop
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const stopLoop = (sessionID: string, id?: string): boolean => {
|
|
150
|
+
const target = id === undefined ? [...loops.values()].find((loop) => loop.sessionID === sessionID) : loops.get(id)
|
|
151
|
+
if (target?.sessionID !== sessionID) return false
|
|
152
|
+
const timer = timers.get(target.id)
|
|
153
|
+
if (timer !== undefined) {
|
|
154
|
+
clearTimeout(timer)
|
|
155
|
+
timers.delete(target.id)
|
|
156
|
+
}
|
|
157
|
+
loops.delete(target.id)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const stopLoopByPrompt = (sessionID: string, prompt: string): boolean => {
|
|
162
|
+
const loop = findLoop(sessionID, prompt)
|
|
163
|
+
return loop === undefined ? false : stopLoop(sessionID, loop.id)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const stopAllLoops = (sessionID: string): number => {
|
|
167
|
+
let count = 0
|
|
168
|
+
for (const loop of [...loops.values()].filter((item) => item.sessionID === sessionID)) {
|
|
169
|
+
if (stopLoop(sessionID, loop.id)) count += 1
|
|
170
|
+
}
|
|
171
|
+
return count
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const listLoops = (sessionID: string): Loop[] => [...loops.values()].filter((loop) => loop.sessionID === sessionID)
|
|
175
|
+
|
|
176
|
+
const scheduleWakeup = (
|
|
177
|
+
sessionID: string,
|
|
178
|
+
prompt: string,
|
|
179
|
+
delaySeconds: number,
|
|
180
|
+
reason: string,
|
|
181
|
+
): ScheduleWakeupResult => {
|
|
182
|
+
const loop = findLoop(sessionID, prompt)
|
|
183
|
+
if (loop?.mode !== "dynamic") {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: "schedule_wakeup is only available inside an active dynamic /loop for this session and prompt.",
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const clamped = Math.min(Math.max(Math.round(delaySeconds), WAKEUP_MIN_S), WAKEUP_MAX_S)
|
|
190
|
+
const now = Date.now()
|
|
191
|
+
const fireAt = Math.ceil((now + clamped * 1000) / 60_000) * 60_000
|
|
192
|
+
loop.pendingWakeup = { fireAt, delaySeconds: clamped, prompt, reason }
|
|
193
|
+
armLoop(loop, fireAt - now)
|
|
194
|
+
return { ok: true, id: loop.id, fireAt }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const clearSession = (sessionID: string): void => {
|
|
198
|
+
for (const loop of listLoops(sessionID)) stopLoop(sessionID, loop.id)
|
|
199
|
+
inFlight.delete(sessionID)
|
|
200
|
+
pending.delete(sessionID)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
startLoop,
|
|
205
|
+
stopLoop,
|
|
206
|
+
stopLoopByPrompt,
|
|
207
|
+
stopAllLoops,
|
|
208
|
+
listLoops,
|
|
209
|
+
scheduleWakeup,
|
|
210
|
+
clearSession,
|
|
211
|
+
|
|
212
|
+
handleEvent(event) {
|
|
213
|
+
const data = readEventData(event)
|
|
214
|
+
const sessionID = data?.["sessionID"]
|
|
215
|
+
if (typeof sessionID !== "string" || sessionID.length === 0) return Promise.resolve()
|
|
216
|
+
|
|
217
|
+
if (event.type === "session.deleted") {
|
|
218
|
+
clearSession(sessionID)
|
|
219
|
+
return Promise.resolve()
|
|
220
|
+
}
|
|
221
|
+
if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
|
|
222
|
+
pending.delete(sessionID)
|
|
223
|
+
}
|
|
224
|
+
return Promise.resolve()
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
dispose() {
|
|
228
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
229
|
+
timers.clear()
|
|
230
|
+
loops.clear()
|
|
231
|
+
inFlight.clear()
|
|
232
|
+
pending.clear()
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import { asRecord, type ScannableStore } from "../shared/json.ts"
|
|
4
|
+
import { SCHEDULE_PREFIX, scheduleKey } from "../shared/keys.ts"
|
|
5
|
+
import { forEachScannedEntry } from "../shared/scan.ts"
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CRON_JITTER_CONFIG,
|
|
8
|
+
jitteredNextCronRunMs,
|
|
9
|
+
nextCronRunMs,
|
|
10
|
+
oneShotJitteredNextCronRunMs,
|
|
11
|
+
parseCronExpression,
|
|
12
|
+
} from "./cron.ts"
|
|
13
|
+
import { JOB_ID_LENGTH, MAX_JOBS, type ScheduledJob } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
/** 存储格式版本。 */
|
|
16
|
+
const STORE_VERSION = 1
|
|
17
|
+
|
|
18
|
+
/** 磁盘上的任务形状:没有运行时字段。 */
|
|
19
|
+
type StoredJob = {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly sessionID: string
|
|
22
|
+
readonly cron: string
|
|
23
|
+
readonly prompt: string
|
|
24
|
+
readonly createdAt: number
|
|
25
|
+
readonly lastFiredAt?: number
|
|
26
|
+
readonly recurring?: boolean
|
|
27
|
+
readonly permanent?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type StoredFile = {
|
|
31
|
+
readonly version: number
|
|
32
|
+
readonly jobs: StoredJob[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 新增任务的输入。 */
|
|
36
|
+
export type AddJobInput = {
|
|
37
|
+
readonly sessionID: string
|
|
38
|
+
readonly cron: string
|
|
39
|
+
readonly prompt: string
|
|
40
|
+
readonly recurring?: boolean
|
|
41
|
+
readonly durable?: boolean
|
|
42
|
+
readonly permanent?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 新增任务的结果。 */
|
|
46
|
+
export type AddJobResult = { ok: true; job: ScheduledJob } | { ok: false; error: string }
|
|
47
|
+
|
|
48
|
+
/** 定时任务存储。 */
|
|
49
|
+
export type SchedulingStore = {
|
|
50
|
+
/** 输入:无;输出:无。预期:从存储恢复任务并重建 nextRunAt,幂等。 */
|
|
51
|
+
load(): Promise<void>
|
|
52
|
+
/** 输入:任务字段;输出:新任务或错误。预期:校验 cron、匹配性与上限。 */
|
|
53
|
+
addJob(input: AddJobInput): Promise<AddJobResult>
|
|
54
|
+
/** 输入:会话 ID(可省);输出:任务列表。 */
|
|
55
|
+
listJobs(sessionID?: string): ScheduledJob[]
|
|
56
|
+
/** 输入:任务 id;输出:任务或 undefined。 */
|
|
57
|
+
getJob(id: string): ScheduledJob | undefined
|
|
58
|
+
/** 输入:任务 id 列表;输出:无。预期:删除并同步持久化。 */
|
|
59
|
+
removeJobs(ids: string[]): Promise<void>
|
|
60
|
+
/** 输入:任务 id 列表 + 触发时刻;输出:无。预期:recurring 重排下一次。 */
|
|
61
|
+
markFired(ids: string[], firedAt: number): Promise<void>
|
|
62
|
+
/** 输入:无;输出:内存中任务总数。 */
|
|
63
|
+
count(): number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 输入:可扫描存储。
|
|
68
|
+
* 输出:定时任务存储。
|
|
69
|
+
* 预期:durable 任务写 `schedule:<sessionID>`;非 durable 只留内存。
|
|
70
|
+
*/
|
|
71
|
+
export function createSchedulingStore(store: ScannableStore): SchedulingStore {
|
|
72
|
+
const jobs = new Map<string, ScheduledJob>()
|
|
73
|
+
|
|
74
|
+
/** 输入:任务;输出:下一次触发毫秒。预期:recurring 带正向抖动,one-shot 带提前量。 */
|
|
75
|
+
const computeNext = (job: ScheduledJob, fromMs: number): number | null =>
|
|
76
|
+
job.recurring === true
|
|
77
|
+
? jitteredNextCronRunMs(job.cron, fromMs, job.id)
|
|
78
|
+
: oneShotJitteredNextCronRunMs(job.cron, fromMs, job.id)
|
|
79
|
+
|
|
80
|
+
/** 输入:任务;输出:可持久化形状。预期:剥离 durable/nextRunAt 等运行时字段。 */
|
|
81
|
+
const toStored = (job: ScheduledJob): StoredJob => ({
|
|
82
|
+
id: job.id,
|
|
83
|
+
sessionID: job.sessionID,
|
|
84
|
+
cron: job.cron,
|
|
85
|
+
prompt: job.prompt,
|
|
86
|
+
createdAt: job.createdAt,
|
|
87
|
+
...(job.lastFiredAt === undefined ? {} : { lastFiredAt: job.lastFiredAt }),
|
|
88
|
+
...(job.recurring === true ? { recurring: true } : {}),
|
|
89
|
+
...(job.permanent === true ? { permanent: true } : {}),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/** 输入:会话 ID;输出:无。预期:只写 durable 任务;没有 durable 任务时删 key。 */
|
|
93
|
+
const persist = async (sessionID: string): Promise<void> => {
|
|
94
|
+
const durable = [...jobs.values()].filter((job) => job.sessionID === sessionID && job.durable !== false)
|
|
95
|
+
if (durable.length === 0) {
|
|
96
|
+
await store.remove(scheduleKey(sessionID))
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const file: StoredFile = { version: STORE_VERSION, jobs: durable.map(toStored) }
|
|
100
|
+
await store.set(scheduleKey(sessionID), file)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 输入:任意值;输出:合法任务或 null。预期:跳过字段缺失/类型错误的条目。 */
|
|
104
|
+
const parseStored = (value: unknown): StoredJob | null => {
|
|
105
|
+
const record = asRecord(value)
|
|
106
|
+
if (record === undefined) return null
|
|
107
|
+
const { id, sessionID, cron, prompt, createdAt } = record
|
|
108
|
+
if (typeof id !== "string" || id.length === 0) return null
|
|
109
|
+
if (typeof sessionID !== "string" || sessionID.length === 0) return null
|
|
110
|
+
if (typeof cron !== "string" || parseCronExpression(cron) === null) return null
|
|
111
|
+
if (typeof prompt !== "string") return null
|
|
112
|
+
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null
|
|
113
|
+
const lastFiredAt = record["lastFiredAt"]
|
|
114
|
+
return {
|
|
115
|
+
id,
|
|
116
|
+
sessionID,
|
|
117
|
+
cron,
|
|
118
|
+
prompt,
|
|
119
|
+
createdAt,
|
|
120
|
+
...(typeof lastFiredAt === "number" && Number.isFinite(lastFiredAt) ? { lastFiredAt } : {}),
|
|
121
|
+
...(record["recurring"] === true ? { recurring: true } : {}),
|
|
122
|
+
...(record["permanent"] === true ? { permanent: true } : {}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
async load() {
|
|
128
|
+
await forEachScannedEntry(store, SCHEDULE_PREFIX, (entry) => {
|
|
129
|
+
const record = asRecord(entry.value)
|
|
130
|
+
if (record === undefined) return Promise.resolve()
|
|
131
|
+
const rawJobs = record["jobs"]
|
|
132
|
+
if (!Array.isArray(rawJobs)) return Promise.resolve()
|
|
133
|
+
|
|
134
|
+
for (const raw of rawJobs) {
|
|
135
|
+
const stored = parseStored(raw)
|
|
136
|
+
if (stored === null) continue
|
|
137
|
+
const job: ScheduledJob = { ...stored, durable: true }
|
|
138
|
+
const next = computeNext(job, job.lastFiredAt ?? job.createdAt)
|
|
139
|
+
if (next !== null) job.nextRunAt = next
|
|
140
|
+
jobs.set(job.id, job)
|
|
141
|
+
}
|
|
142
|
+
return Promise.resolve()
|
|
143
|
+
})
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async addJob(input) {
|
|
147
|
+
const { sessionID, cron, prompt, recurring = false, durable = false, permanent = false } = input
|
|
148
|
+
if (parseCronExpression(cron) === null) {
|
|
149
|
+
return { ok: false, error: `Invalid cron expression '${cron}'. Expected 5 fields: M H DoM Mon DoW.` }
|
|
150
|
+
}
|
|
151
|
+
if (nextCronRunMs(cron, Date.now()) === null) {
|
|
152
|
+
return { ok: false, error: `Cron expression '${cron}' does not match any calendar date in the next year.` }
|
|
153
|
+
}
|
|
154
|
+
const sessionJobs = [...jobs.values()].filter((job) => job.sessionID === sessionID).length
|
|
155
|
+
if (sessionJobs >= MAX_JOBS) {
|
|
156
|
+
return { ok: false, error: `Too many scheduled jobs (max ${String(MAX_JOBS)} per session). Cancel one first.` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const now = Date.now()
|
|
160
|
+
const job: ScheduledJob = {
|
|
161
|
+
id: randomUUID().slice(0, JOB_ID_LENGTH),
|
|
162
|
+
sessionID,
|
|
163
|
+
cron,
|
|
164
|
+
prompt,
|
|
165
|
+
createdAt: now,
|
|
166
|
+
...(recurring ? { recurring: true } : {}),
|
|
167
|
+
...(permanent ? { permanent: true } : {}),
|
|
168
|
+
durable,
|
|
169
|
+
}
|
|
170
|
+
const next = computeNext(job, now)
|
|
171
|
+
if (next !== null) job.nextRunAt = next
|
|
172
|
+
jobs.set(job.id, job)
|
|
173
|
+
if (durable) await persist(sessionID)
|
|
174
|
+
return { ok: true, job }
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
listJobs(sessionID) {
|
|
178
|
+
const all = [...jobs.values()]
|
|
179
|
+
return sessionID === undefined ? all : all.filter((job) => job.sessionID === sessionID)
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
getJob(id) {
|
|
183
|
+
return jobs.get(id)
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async removeJobs(ids) {
|
|
187
|
+
const touched = new Set<string>()
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const job = jobs.get(id)
|
|
190
|
+
if (job === undefined) continue
|
|
191
|
+
touched.add(job.sessionID)
|
|
192
|
+
jobs.delete(id)
|
|
193
|
+
}
|
|
194
|
+
for (const sessionID of touched) await persist(sessionID)
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async markFired(ids, firedAt) {
|
|
198
|
+
const touched = new Set<string>()
|
|
199
|
+
for (const id of ids) {
|
|
200
|
+
const job = jobs.get(id)
|
|
201
|
+
if (job?.recurring !== true) continue
|
|
202
|
+
touched.add(job.sessionID)
|
|
203
|
+
job.lastFiredAt = firedAt
|
|
204
|
+
const next = computeNext(job, firedAt)
|
|
205
|
+
if (next !== null) job.nextRunAt = next
|
|
206
|
+
}
|
|
207
|
+
for (const sessionID of touched) await persist(sessionID)
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
count() {
|
|
211
|
+
return jobs.size
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 输入:任务 + 当前时刻。
|
|
218
|
+
* 输出:是否已过 7 天自动过期。
|
|
219
|
+
* 预期:one-shot、permanent 与 `maxAgeMs === 0` 都不过期。
|
|
220
|
+
*/
|
|
221
|
+
export function isRecurringTaskAged(
|
|
222
|
+
task: ScheduledJob,
|
|
223
|
+
nowMs: number,
|
|
224
|
+
maxAgeMs: number = DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs,
|
|
225
|
+
): boolean {
|
|
226
|
+
if (maxAgeMs === 0) return false
|
|
227
|
+
return task.recurring === true && task.permanent !== true && nowMs - task.createdAt >= maxAgeMs
|
|
228
|
+
}
|