Repository navigation
Expand file tree
/
Copy pathindex.ts
More file actions
168 lines (151 loc) · 6.85 KB
/
Copy pathindex.ts
File metadata and controls
168 lines (151 loc) · 6.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import { logger } from "./logger"
import type { State, PluginConfig } from "./types"
import { ReminderSchema } from "./types"
import { createHost, type PluginContextLike, type ReminderHost } from "./host"
import { getStorageDir, listReminders } from "./storage"
import {
beginSchedulerGeneration,
isSchedulerGenerationCurrent,
waitForSchedulerMutations,
scheduleTimer,
cancelReminder,
cleanupReminderSnapshot,
} from "./scheduler"
import { createReminderAddTool } from "./tools/reminderadd"
import { createReminderListTool } from "./tools/reminderlist"
import { createReminderPauseTool } from "./tools/reminderpause"
import { createReminderRemoveTool } from "./tools/reminderremove"
import type { ToolInfo } from "./tools/tool"
type ToolEditor = { add(tool: ToolInfo): void }
type PluginContext = PluginContextLike & {
options?: Record<string, unknown>
tool: { transform(callback: (editor: ToolEditor) => void): Promise<unknown> }
}
const DEFAULT_CONFIG: PluginConfig = {
enabled: true,
max_reminders_per_project: 50,
min_interval_seconds: 30,
}
function readConfig(options: Record<string, unknown> | undefined): PluginConfig {
const config = { ...DEFAULT_CONFIG }
if (!options) return config
if (typeof options.enabled === "boolean") config.enabled = options.enabled
if (typeof options.max_reminders_per_project === "number" && options.max_reminders_per_project > 0) {
config.max_reminders_per_project = Math.floor(options.max_reminders_per_project)
}
if (typeof options.min_interval_seconds === "number" && options.min_interval_seconds >= 30) {
config.min_interval_seconds = Math.floor(options.min_interval_seconds)
}
return config
}
function isSafeRestoredReminder(reminder: { id: string; time: { nextExecution: number; created: number } }): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reminder.id)
&& Number.isSafeInteger(reminder.time.created)
&& Number.isSafeInteger(reminder.time.nextExecution)
}
/** Restore persisted reminders for the host's project. Exported for tests. */
export async function restoreReminders(host: ReminderHost, state: State, config: PluginConfig, generation: string) {
const gracePeriod = 60 * 60 * 1000
const now = Date.now()
const counts = { restored: 0, expired: 0, invalid: 0 }
if (!(await waitForSchedulerMutations(host, generation))) return false
const storedReminders = await listReminders(host)
if (!isSchedulerGenerationCurrent(host, generation)) return false
for (const snapshot of storedReminders) {
if (!isSchedulerGenerationCurrent(host, generation)) return false
const parsed = ReminderSchema.safeParse(snapshot)
// Persisted filenames are derived from IDs. Only generated UUID snapshots with safe
// timestamps may be re-opened or cleaned; malformed files are left for manual repair.
if (!parsed.success || !isSafeRestoredReminder(parsed.data)) {
logger.error(`[RemindersPlugin] Invalid restored reminder left untouched`)
counts.invalid++
continue
}
const reminder = parsed.data
try {
if (reminder.status === "paused") {
// Paused snapshots retain their reason and never resume automatically.
state.reminders.set(reminder.id, reminder)
continue
}
if (reminder.status !== "active") continue
if (reminder.time.nextExecution + gracePeriod < now) {
if (await cleanupReminderSnapshot(reminder, host, state, config)) {
logger.info(`[RemindersPlugin] Reminder ${reminder.id} expired, removing`)
counts.expired++
}
if (!isSchedulerGenerationCurrent(host, generation)) return false
continue
}
// Preserve historical failure counts when restoring old snapshots.
state.reminders.set(reminder.id, reminder)
const scheduled = await scheduleTimer(reminder, host, state, config, { persist: false, skipOverdue: true })
if (!isSchedulerGenerationCurrent(host, generation)) return false
if (scheduled && state.timers.has(reminder.id)) {
counts.restored++
logger.info(`[RemindersPlugin] Restored and validated reminder ${reminder.id}`)
} else {
await cleanupReminderSnapshot(reminder, host, state, config)
state.reminders.delete(reminder.id)
counts.invalid++
logger.error(`[RemindersPlugin] Timer restoration failed for ${reminder.id}, cancelled reminder`)
}
} catch (error) {
logger.error(`[RemindersPlugin] Failed to restore reminder:`, error)
await cleanupReminderSnapshot(reminder, host, state, config)
counts.invalid++
}
}
logger.info(
`[RemindersPlugin] Timer persistence validation completed: ${storedReminders.length} total, ${counts.restored} restored, ${counts.expired} expired, ${counts.invalid} invalid`,
)
return true
}
export function createTools(host: ReminderHost, state: State, getConfig: () => PluginConfig): ToolInfo[] {
return [
createReminderPauseTool(host, state),
createReminderAddTool(host, state, getConfig),
createReminderListTool(state),
createReminderRemoveTool(host, state, getConfig),
]
}
const RemindersPlugin = {
id: "opencode-reminders",
async setup(ctx: PluginContext) {
const config = readConfig(ctx.options)
const state: State = { reminders: new Map(), timers: new Map(), projectID: ctx.location.project.id }
const tracker = createHost(ctx, async (event) => {
if (event.type !== "session.deleted") return
const sessionID = typeof event.data?.sessionID === "string" ? event.data.sessionID : undefined
if (!sessionID) return
logger.info(`[RemindersPlugin] Session ${sessionID} deleted, cleaning up reminders`)
let cancelled = 0
for (const reminder of Array.from(state.reminders.values()).filter((r) => r.sessionID === sessionID)) {
if (await cancelReminder(reminder.id, host, state, config)) cancelled++
}
logger.info(`[RemindersPlugin] Cancelled ${cancelled} reminders for session ${sessionID}`)
})
const host = tracker.host
const generation = beginSchedulerGeneration(host)
state.generation = generation
logger.info(`[RemindersPlugin] Initialising for project ${host.projectID}`)
const cleanup = async () => {
tracker.stop()
// A replacement generation clears timers and aborts active executions from this instance.
if (isSchedulerGenerationCurrent(host, generation)) beginSchedulerGeneration(host)
await tracker.done.catch(() => {})
}
if (!config.enabled) {
logger.info("[RemindersPlugin] Disabled by plugin options")
return cleanup
}
await getStorageDir(host)
if (!(await restoreReminders(host, state, config, generation))) return cleanup
const tools = createTools(host, state, () => config)
await ctx.tool.transform((editor) => {
for (const tool of tools) editor.add(tool)
})
return cleanup
},
}
export default RemindersPlugin