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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/index.ts +3 -0
  4. package/package.json +65 -4
  5. package/src/goal/command.ts +84 -0
  6. package/src/goal/continuation.ts +211 -0
  7. package/src/goal/controller.ts +107 -0
  8. package/src/goal/error.ts +94 -0
  9. package/src/goal/filter.ts +21 -0
  10. package/src/goal/index.ts +43 -0
  11. package/src/goal/parse-command.ts +31 -0
  12. package/src/goal/prompts.ts +84 -0
  13. package/src/goal/session-role.ts +37 -0
  14. package/src/goal/store.ts +91 -0
  15. package/src/goal/tools.ts +131 -0
  16. package/src/goal/types.ts +75 -0
  17. package/src/i18n/en.ts +61 -0
  18. package/src/i18n/index.ts +28 -0
  19. package/src/i18n/types.ts +57 -0
  20. package/src/i18n/zh.ts +61 -0
  21. package/src/index.ts +150 -0
  22. package/src/notification/format.ts +42 -0
  23. package/src/notification/index.ts +165 -0
  24. package/src/notification/send.ts +104 -0
  25. package/src/scheduling/command.ts +181 -0
  26. package/src/scheduling/continuation.ts +164 -0
  27. package/src/scheduling/cron.ts +309 -0
  28. package/src/scheduling/filter.ts +25 -0
  29. package/src/scheduling/index.ts +68 -0
  30. package/src/scheduling/loops.ts +235 -0
  31. package/src/scheduling/store.ts +228 -0
  32. package/src/scheduling/tools.ts +183 -0
  33. package/src/scheduling/types.ts +54 -0
  34. package/src/shared/events.ts +44 -0
  35. package/src/shared/format.ts +16 -0
  36. package/src/shared/json.ts +44 -0
  37. package/src/shared/keys.ts +26 -0
  38. package/src/shared/messages.ts +17 -0
  39. package/src/shared/rpc.ts +214 -0
  40. package/src/shared/scan.ts +21 -0
  41. package/src/shared/settings-store.ts +28 -0
  42. package/src/shared/settings.ts +133 -0
  43. package/src/shared/types.ts +4 -0
  44. package/src/stats.ts +215 -0
  45. package/src/tools/ping.ts +40 -0
  46. package/src/tui/index.tsx +274 -0
  47. 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
+ }