@hybrd/scheduler 2.0.0

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.
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { SqliteSchedulerStore } from './src/store.js'
6
+ import { SchedulerService, createSchedulerService } from './src/index.js'
7
+
8
+ describe('Scheduler Integration Eval', () => {
9
+ let tempDir: string
10
+ let store: SqliteSchedulerStore
11
+ let scheduler: SchedulerService
12
+
13
+ beforeEach(() => {
14
+ tempDir = mkdtempSync(join(tmpdir(), 'eval-scheduler-'))
15
+ })
16
+
17
+ afterEach(() => {
18
+ if (existsSync(tempDir)) {
19
+ rmSync(tempDir, { recursive: true, force: true })
20
+ }
21
+ scheduler?.stop()
22
+ })
23
+
24
+ it('initializes scheduler with SQLite', async () => {
25
+ store = new SqliteSchedulerStore({ dbPath: join(tempDir, 'scheduler.db') })
26
+ await store.init()
27
+
28
+ scheduler = await createSchedulerService({
29
+ store,
30
+ dispatcher: {
31
+ dispatch: vi.fn().mockResolvedValue({ delivered: true })
32
+ },
33
+ executor: {
34
+ runAgentTurn: vi.fn().mockResolvedValue({ status: 'ok' }),
35
+ runSystemEvent: vi.fn().mockResolvedValue({ status: 'ok' })
36
+ },
37
+ enabled: false
38
+ })
39
+
40
+ expect(scheduler).toBeDefined()
41
+ })
42
+
43
+ it('creates and lists scheduled tasks', async () => {
44
+ store = new SqliteSchedulerStore({ dbPath: join(tempDir, 'scheduler.db') })
45
+ await store.init()
46
+
47
+ scheduler = await createSchedulerService({
48
+ store,
49
+ dispatcher: {
50
+ dispatch: vi.fn().mockResolvedValue({ delivered: true })
51
+ },
52
+ executor: {
53
+ runAgentTurn: vi.fn().mockResolvedValue({ status: 'ok' }),
54
+ runSystemEvent: vi.fn().mockResolvedValue({ status: 'ok' })
55
+ },
56
+ enabled: false
57
+ })
58
+
59
+ const task = await scheduler.add({
60
+ agentId: 'eval-agent',
61
+ name: 'eval-test-task',
62
+ schedule: { kind: 'every', everyMs: 60000 },
63
+ payload: { kind: 'systemEvent', text: 'Test event' },
64
+ enabled: true
65
+ })
66
+
67
+ expect(task.id).toBeDefined()
68
+
69
+ const tasks = await scheduler.list()
70
+ expect(tasks.length).toBe(1)
71
+ expect(tasks[0].name).toBe('eval-test-task')
72
+ })
73
+
74
+ it('cancels scheduled tasks', async () => {
75
+ store = new SqliteSchedulerStore({ dbPath: join(tempDir, 'scheduler.db') })
76
+ await store.init()
77
+
78
+ scheduler = await createSchedulerService({
79
+ store,
80
+ dispatcher: {
81
+ dispatch: vi.fn().mockResolvedValue({ delivered: true })
82
+ },
83
+ executor: {
84
+ runAgentTurn: vi.fn().mockResolvedValue({ status: 'ok' }),
85
+ runSystemEvent: vi.fn().mockResolvedValue({ status: 'ok' })
86
+ },
87
+ enabled: false
88
+ })
89
+
90
+ const task = await scheduler.add({
91
+ agentId: 'eval-agent',
92
+ name: 'cancellable-task',
93
+ schedule: { kind: 'every', everyMs: 60000 },
94
+ payload: { kind: 'systemEvent', text: 'Test event' },
95
+ enabled: true
96
+ })
97
+
98
+ await scheduler.remove(task.id)
99
+
100
+ const tasks = await scheduler.list()
101
+ expect(tasks.length).toBe(0)
102
+ })
103
+
104
+ it('handles cron expressions', async () => {
105
+ store = new SqliteSchedulerStore({ dbPath: join(tempDir, 'scheduler.db') })
106
+ await store.init()
107
+
108
+ scheduler = await createSchedulerService({
109
+ store,
110
+ dispatcher: {
111
+ dispatch: vi.fn().mockResolvedValue({ delivered: true })
112
+ },
113
+ executor: {
114
+ runAgentTurn: vi.fn().mockResolvedValue({ status: 'ok' }),
115
+ runSystemEvent: vi.fn().mockResolvedValue({ status: 'ok' })
116
+ },
117
+ enabled: false
118
+ })
119
+
120
+ const task = await scheduler.add({
121
+ agentId: 'eval-agent',
122
+ name: 'cron-task',
123
+ schedule: { kind: 'cron', expr: '* * * * *' },
124
+ payload: { kind: 'systemEvent', text: 'Cron test' },
125
+ enabled: true
126
+ })
127
+
128
+ expect(task.id).toBeDefined()
129
+
130
+ const retrieved = await scheduler.get(task.id)
131
+ expect(retrieved?.schedule.kind).toBe('cron')
132
+ })
133
+
134
+ it('updates scheduled tasks', async () => {
135
+ store = new SqliteSchedulerStore({ dbPath: join(tempDir, 'scheduler.db') })
136
+ await store.init()
137
+
138
+ scheduler = await createSchedulerService({
139
+ store,
140
+ dispatcher: {
141
+ dispatch: vi.fn().mockResolvedValue({ delivered: true })
142
+ },
143
+ executor: {
144
+ runAgentTurn: vi.fn().mockResolvedValue({ status: 'ok' }),
145
+ runSystemEvent: vi.fn().mockResolvedValue({ status: 'ok' })
146
+ },
147
+ enabled: false
148
+ })
149
+
150
+ const task = await scheduler.add({
151
+ agentId: 'eval-agent',
152
+ name: 'original-name',
153
+ schedule: { kind: 'every', everyMs: 60000 },
154
+ payload: { kind: 'systemEvent', text: 'Test event' },
155
+ enabled: true
156
+ })
157
+
158
+ await scheduler.update(task.id, { name: 'updated-name', enabled: false })
159
+
160
+ const retrieved = await scheduler.get(task.id)
161
+ expect(retrieved?.name).toBe('updated-name')
162
+ expect(retrieved?.enabled).toBe(false)
163
+ })
164
+ })
@@ -0,0 +1,21 @@
1
+ declare module "sql.js" {
2
+ export interface Database {
3
+ run(sql: string, params?: unknown[]): void
4
+ exec(sql: string, params?: unknown[]): QueryExecResult[]
5
+ export(): Uint8Array
6
+ close(): void
7
+ }
8
+
9
+ export interface QueryExecResult {
10
+ columns: string[]
11
+ values: unknown[][]
12
+ }
13
+
14
+ export interface SqlJsStatic {
15
+ Database: new (data?: ArrayLike<number>) => Database
16
+ }
17
+
18
+ export default function initSqlJs(config?: {
19
+ locateFile?: (file: string) => string
20
+ }): Promise<SqlJsStatic>
21
+ }
package/src/store.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+ import initSqlJs from "sql.js"
5
+
6
+ import type {
7
+ CronJob,
8
+ CronJobState,
9
+ CronPayload,
10
+ CronSchedule,
11
+ SessionTarget,
12
+ WakeMode
13
+ } from "@hybrd/types"
14
+
15
+ const SCHEMA = `
16
+ CREATE TABLE IF NOT EXISTS cron_jobs (
17
+ id TEXT PRIMARY KEY,
18
+ agent_id TEXT,
19
+ session_key TEXT,
20
+ name TEXT NOT NULL,
21
+ description TEXT,
22
+ enabled INTEGER NOT NULL DEFAULT 1,
23
+ delete_after_run INTEGER,
24
+ created_at_ms INTEGER NOT NULL,
25
+ updated_at_ms INTEGER NOT NULL,
26
+ schedule TEXT NOT NULL,
27
+ session_target TEXT NOT NULL DEFAULT 'isolated',
28
+ wake_mode TEXT NOT NULL DEFAULT 'now',
29
+ payload TEXT NOT NULL,
30
+ delivery TEXT,
31
+ state TEXT NOT NULL
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(id);
35
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled ON cron_jobs(enabled);
36
+ `
37
+
38
+ export interface SqliteSchedulerStoreOptions {
39
+ dbPath?: string
40
+ onSave?: (data: Uint8Array) => void | Promise<void>
41
+ loadOnInit?: boolean
42
+ }
43
+
44
+ type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>
45
+ type SqlJsDatabase = InstanceType<SqlJsStatic["Database"]>
46
+
47
+ export class SqliteSchedulerStore {
48
+ private db: SqlJsDatabase | null = null
49
+ private dbPath: string
50
+ private onSave?: (data: Uint8Array) => void | Promise<void>
51
+ private loadOnInit: boolean
52
+ private initPromise: Promise<void> | null = null
53
+ private dirty = false
54
+ private saveTimer?: ReturnType<typeof setTimeout>
55
+ private jobsCache: Map<string, CronJob> | null = null
56
+
57
+ constructor(options: SqliteSchedulerStoreOptions = {}) {
58
+ this.dbPath = options.dbPath ?? ":memory:"
59
+ this.onSave = options.onSave
60
+ this.loadOnInit = options.loadOnInit ?? true
61
+ }
62
+
63
+ async init(): Promise<void> {
64
+ if (this.initPromise) {
65
+ return this.initPromise
66
+ }
67
+
68
+ this.initPromise = this._init()
69
+ return this.initPromise
70
+ }
71
+
72
+ private async _init(): Promise<void> {
73
+ let SQL: SqlJsStatic
74
+ try {
75
+ const req = createRequire(process.cwd())
76
+ const sqlJsPath = dirname(req.resolve("sql.js"))
77
+ const wasmPath = join(sqlJsPath, "sql-wasm.wasm")
78
+ const wasmBinary = readFileSync(wasmPath)
79
+ SQL = await initSqlJs({ wasmBinary } as any)
80
+ } catch {
81
+ SQL = await initSqlJs()
82
+ }
83
+
84
+ if (this.dbPath === ":memory:" || !this.loadOnInit) {
85
+ this.db = new SQL.Database()
86
+ } else {
87
+ try {
88
+ const buffer = readFileSync(this.dbPath)
89
+ this.db = new SQL.Database(new Uint8Array(buffer))
90
+ } catch {
91
+ this.db = new SQL.Database()
92
+ }
93
+ }
94
+
95
+ this.db.run(SCHEMA)
96
+ this.loadCache()
97
+ this.scheduleSave()
98
+ }
99
+
100
+ private loadCache(): void {
101
+ if (!this.db) return
102
+ const cache = new Map<string, CronJob>()
103
+
104
+ const result = this.db.exec(
105
+ "SELECT id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state FROM cron_jobs"
106
+ )
107
+
108
+ const firstResult = result[0]
109
+ if (firstResult) {
110
+ for (const row of firstResult.values) {
111
+ const job = this.rowToJob(row as unknown[])
112
+ if (job) {
113
+ cache.set(job.id, job)
114
+ }
115
+ }
116
+ }
117
+
118
+ this.jobsCache = cache
119
+ }
120
+
121
+ private scheduleSave(): void {
122
+ if (this.saveTimer) {
123
+ clearTimeout(this.saveTimer)
124
+ }
125
+
126
+ this.saveTimer = setTimeout(() => {
127
+ void this.save()
128
+ }, 1000)
129
+ }
130
+
131
+ async save(): Promise<void> {
132
+ if (!this.db || !this.dirty) return
133
+
134
+ const data = this.db.export()
135
+ this.dirty = false
136
+
137
+ if (this.onSave) {
138
+ await this.onSave(data)
139
+ }
140
+ }
141
+
142
+ async close(): Promise<void> {
143
+ if (this.saveTimer) {
144
+ clearTimeout(this.saveTimer)
145
+ }
146
+ await this.save()
147
+ this.db?.close()
148
+ this.db = null
149
+ this.jobsCache = null
150
+ }
151
+
152
+ private rowToJob(row: unknown[]): CronJob | null {
153
+ try {
154
+ const schedule = JSON.parse(String(row[9])) as CronSchedule
155
+ const payload = JSON.parse(String(row[12])) as CronPayload
156
+ const delivery = row[13] ? JSON.parse(String(row[13])) : undefined
157
+ const state = JSON.parse(String(row[14])) as CronJobState
158
+
159
+ return {
160
+ id: String(row[0]),
161
+ agentId: row[1] ? String(row[1]) : undefined,
162
+ sessionKey: row[2] ? String(row[2]) : undefined,
163
+ name: String(row[3]),
164
+ description: row[4] ? String(row[4]) : undefined,
165
+ enabled: Boolean(row[5]),
166
+ deleteAfterRun: row[6] ? Boolean(row[6]) : undefined,
167
+ createdAtMs: Number(row[7]),
168
+ updatedAtMs: Number(row[8]),
169
+ schedule,
170
+ sessionTarget: (row[10] as SessionTarget) ?? "isolated",
171
+ wakeMode: (row[11] as WakeMode) ?? "now",
172
+ payload,
173
+ delivery,
174
+ state
175
+ }
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ private jobToRow(job: CronJob): unknown[] {
182
+ return [
183
+ job.id,
184
+ job.agentId ?? null,
185
+ job.sessionKey ?? null,
186
+ job.name,
187
+ job.description ?? null,
188
+ job.enabled ? 1 : 0,
189
+ job.deleteAfterRun ? 1 : null,
190
+ job.createdAtMs,
191
+ job.updatedAtMs,
192
+ JSON.stringify(job.schedule),
193
+ job.sessionTarget,
194
+ job.wakeMode,
195
+ JSON.stringify(job.payload),
196
+ job.delivery ? JSON.stringify(job.delivery) : null,
197
+ JSON.stringify(job.state)
198
+ ]
199
+ }
200
+
201
+ private ensureDb(): SqlJsDatabase {
202
+ if (!this.db) {
203
+ throw new Error("Store not initialized. Call init() first.")
204
+ }
205
+ return this.db
206
+ }
207
+
208
+ private ensureCache(): Map<string, CronJob> {
209
+ if (!this.jobsCache) {
210
+ throw new Error("Cache not initialized. Call init() first.")
211
+ }
212
+ return this.jobsCache
213
+ }
214
+
215
+ getJobSync(id: string): CronJob | undefined {
216
+ const cache = this.ensureCache()
217
+ return cache.get(id)
218
+ }
219
+
220
+ getAllJobsSync(): CronJob[] {
221
+ const cache = this.ensureCache()
222
+ return Array.from(cache.values())
223
+ }
224
+
225
+ saveJobSync(job: CronJob): void {
226
+ const db = this.ensureDb()
227
+ const cache = this.ensureCache()
228
+
229
+ db.run(
230
+ `INSERT OR REPLACE INTO cron_jobs
231
+ (id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
233
+ this.jobToRow(job)
234
+ )
235
+
236
+ cache.set(job.id, job)
237
+ this.dirty = true
238
+ this.scheduleSave()
239
+ }
240
+
241
+ saveAllJobsSync(): void {
242
+ const db = this.ensureDb()
243
+ const cache = this.ensureCache()
244
+
245
+ for (const job of cache.values()) {
246
+ db.run(
247
+ `INSERT OR REPLACE INTO cron_jobs
248
+ (id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state)
249
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
250
+ this.jobToRow(job)
251
+ )
252
+ }
253
+
254
+ this.dirty = true
255
+ this.scheduleSave()
256
+ }
257
+
258
+ deleteJobSync(id: string): void {
259
+ const db = this.ensureDb()
260
+ const cache = this.ensureCache()
261
+
262
+ db.run("DELETE FROM cron_jobs WHERE id = ?", [id])
263
+ cache.delete(id)
264
+ this.dirty = true
265
+ this.scheduleSave()
266
+ }
267
+
268
+ async getTask(id: string): Promise<CronJob | undefined> {
269
+ return this.getJobSync(id)
270
+ }
271
+
272
+ async getAllTasks(): Promise<CronJob[]> {
273
+ return this.getAllJobsSync()
274
+ }
275
+
276
+ async saveTask(task: CronJob): Promise<void> {
277
+ this.saveJobSync(task)
278
+ }
279
+
280
+ async deleteTask(id: string): Promise<void> {
281
+ this.deleteJobSync(id)
282
+ }
283
+ }
284
+
285
+ export async function createSqliteStore(
286
+ options?: SqliteSchedulerStoreOptions
287
+ ): Promise<SqliteSchedulerStore> {
288
+ const resolvedOptions = { ...options }
289
+
290
+ // If a real dbPath is provided but no onSave callback, add a default
291
+ // file-write callback so that changes are actually persisted to disk.
292
+ if (
293
+ resolvedOptions.dbPath &&
294
+ resolvedOptions.dbPath !== ":memory:" &&
295
+ !resolvedOptions.onSave
296
+ ) {
297
+ const { writeFileSync } = await import("node:fs")
298
+ const { dirname } = await import("node:path")
299
+ const { mkdirSync, existsSync } = await import("node:fs")
300
+ const dbPath = resolvedOptions.dbPath
301
+
302
+ // Ensure the parent directory exists
303
+ const dir = dirname(dbPath)
304
+ if (!existsSync(dir)) {
305
+ mkdirSync(dir, { recursive: true })
306
+ }
307
+
308
+ resolvedOptions.onSave = (data: Uint8Array) => {
309
+ writeFileSync(dbPath, data)
310
+ }
311
+ }
312
+
313
+ const store = new SqliteSchedulerStore(resolvedOptions)
314
+ await store.init()
315
+ return store
316
+ }