@omg-dev/server 0.4.24

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,837 @@
1
+ // Cron + event triggers for vibes apps.
2
+ //
3
+ // User-facing API:
4
+ // import { cron, on, emit } from "@omg-dev/server"
5
+ //
6
+ // export const purge = cron("0 3 * * *", async (ctx) => { ... })
7
+ // export const welcome = on("user.signup", async (ctx, payload) => { ... })
8
+ // await emit("user.signup", { userId, email })
9
+ //
10
+ // Runtime modes:
11
+ // - VIBES_MODE=prod (default in deployed bundles): emit() POSTs to the
12
+ // in-VM agent's /_emit endpoint, which forwards to the orchestrator
13
+ // queue. Cron and on are fired by the orchestrator via POST to
14
+ // /_vibes/dispatch on this server.
15
+ // - VIBES_MODE=dev (set by the vite-plugin during `bun dev`): emit() uses
16
+ // an in-process pub/sub bus; cron is scheduled with a setTimeout/clear
17
+ // loop reading the parsed cron expression. Same handler functions run
18
+ // in both modes.
19
+ //
20
+ // The vite-plugin scanner detects `cron("…", fn)` and `on("…", fn)` call
21
+ // sites in functions/*.ts at build/HMR time and emits .vibes/triggers.json
22
+ // — that file is the source of truth for which handlers are registered.
23
+ // This module reads it at boot (createVibesServer) and (re-)registers.
24
+
25
+ import path from "node:path"
26
+ import fs from "node:fs"
27
+ import { ctxStore, type VibesCtx } from "./ctx.ts"
28
+
29
+ // ── Public API ───────────────────────────────────────────────────────────────
30
+
31
+ export type CronHandler = (ctx: VibesCtx) => unknown | Promise<unknown>
32
+ export type EventHandler<T = unknown> = (ctx: VibesCtx, payload: T) => unknown | Promise<unknown>
33
+
34
+ /**
35
+ * Schedule a function to run on a cron expression (UTC, 5-field syntax).
36
+ * The schedule must be a literal string — dynamic values will be rejected by
37
+ * the build-time scanner so the orchestrator can register the trigger.
38
+ *
39
+ * Returns the handler unchanged so it can also be invoked directly in tests.
40
+ */
41
+ export function cron(schedule: string, handler: CronHandler): CronHandler {
42
+ // Function is also registered side-effect-free here so unit tests that
43
+ // import the file get the registration too (the live registry is populated
44
+ // explicitly via registerTrigger() at boot from .vibes/triggers.json).
45
+ return handler
46
+ }
47
+
48
+ /**
49
+ * Subscribe a function to an event topic. The topic must be a literal string.
50
+ */
51
+ export function on<T = unknown>(topic: string, handler: EventHandler<T>): EventHandler<T> {
52
+ return handler
53
+ }
54
+
55
+ /**
56
+ * Emit an event. Mode-aware: posts to the orchestrator in prod; runs
57
+ * subscribers in-process in dev.
58
+ *
59
+ * Payload is JSON-serialized; total size capped at 64 KB (matched against
60
+ * the orchestrator-side limit).
61
+ */
62
+ export async function emit(topic: string, payload: unknown = null): Promise<{ subscriberCount: number }> {
63
+ if (vibesMode() === "dev") {
64
+ return emitInProcess(topic, payload)
65
+ }
66
+ return emitToOrchestrator(topic, payload)
67
+ }
68
+
69
+ /**
70
+ * Schedule a one-shot delayed fire of `topic`. Same fan-out semantics as
71
+ * `emit()` (every `on(topic, …)` subscriber receives one delivery), but
72
+ * each delivery runs at `atMs` instead of immediately.
73
+ *
74
+ * `atMs` is unix milliseconds. Past timestamps fire ASAP. The orchestrator
75
+ * caps the future horizon at 365 days.
76
+ *
77
+ * Returns `{ eventId, subscriberCount, scheduledFor }`. Hold onto `eventId`
78
+ * if you might need to `cancel(eventId)` before it fires.
79
+ *
80
+ * Example:
81
+ * const { eventId } = await schedule(reminder.dueAt, "reminder.due", { id })
82
+ * // …later, if user deletes the reminder:
83
+ * await cancel(eventId)
84
+ */
85
+ export async function schedule(
86
+ atMs: number,
87
+ topic: string,
88
+ payload: unknown = null,
89
+ ): Promise<{ eventId: string; subscriberCount: number; scheduledFor: number }> {
90
+ if (!Number.isFinite(atMs) || atMs < 0) {
91
+ throw new Error(`schedule("${topic}"): at must be a unix-ms number`)
92
+ }
93
+ if (!topic || typeof topic !== "string") {
94
+ throw new Error("schedule: topic required")
95
+ }
96
+ if (vibesMode() === "dev") {
97
+ return scheduleInProcess(atMs, topic, payload)
98
+ }
99
+ return scheduleToOrchestrator(atMs, topic, payload)
100
+ }
101
+
102
+ /**
103
+ * Cancel the pending deliveries created by a previous `schedule()`. At-most-
104
+ * once semantics: anything already claimed or fired is past the cancel
105
+ * window and is left untouched.
106
+ *
107
+ * Returns `{ cancelled }`, the number of pending deliveries dropped. Zero
108
+ * is a valid response meaning "nothing left to cancel" (already fired, or
109
+ * never scheduled with this eventId).
110
+ */
111
+ export async function cancel(eventId: string): Promise<{ cancelled: number }> {
112
+ if (!eventId || typeof eventId !== "string") {
113
+ throw new Error("cancel: eventId required")
114
+ }
115
+ if (vibesMode() === "dev") {
116
+ return cancelInProcess(eventId)
117
+ }
118
+ return cancelOnOrchestrator(eventId)
119
+ }
120
+
121
+ // ── Registry ─────────────────────────────────────────────────────────────────
122
+
123
+ interface TriggerEntry {
124
+ // dispatch identifier — what arrives in POST /_vibes/dispatch body.handler
125
+ // Convention: "<file-basename>.<exportName>"
126
+ handler: string
127
+ kind: "cron" | "on" | "storage:upload" | "storage:delete"
128
+ key: string // cron expression / topic; empty for storage hooks
129
+ module: string // file path to import (dev) — ignored when `mod` is set
130
+ exportName: string
131
+ mod?: Record<string, unknown> // preloaded for prod bundles
132
+ }
133
+
134
+ interface ResolvedHandler {
135
+ entry: TriggerEntry
136
+ fn: CronHandler | EventHandler
137
+ }
138
+
139
+ const triggerRegistry = new Map<string, ResolvedHandler>()
140
+ // Event subscriptions, indexed by topic for fast emit() lookup.
141
+ const topicSubscribers = new Map<string, Set<string>>() // topic → set of handler names
142
+ // Storage hooks, indexed by kind. Fired by the auto-injected
143
+ // /api/_storage/notify route after a successful upload / delete.
144
+ const storageHookHandlers = new Map<"storage:upload" | "storage:delete", Set<string>>()
145
+
146
+ // In-process dev scheduler state.
147
+ const cronIntervals = new Map<string, ReturnType<typeof setInterval>>()
148
+
149
+ // ── Cron driver ──────────────────────────────────────────────────────────────
150
+ // Which machinery fires cron() handlers:
151
+ // - "orchestrator" (prod default): the orchestrator's ticker enqueues
152
+ // deliveries and the dispatcher POSTs /_vibes/dispatch into this server.
153
+ // Requires the app to be an orchestrator-managed deploy — a plain
154
+ // container (control-plane, self-host bundle) never receives dispatches
155
+ // and its crons silently never fire.
156
+ // - "in-process": the same setInterval scheduler dev mode uses, armed at
157
+ // registerTriggers() time. For hosts that run as standalone containers.
158
+ // Dev mode always schedules in-process regardless of this setting. Only
159
+ // affects cron — emit()/schedule() routing still follows vibesMode().
160
+
161
+ export type CronDriver = "orchestrator" | "in-process"
162
+
163
+ let _cronDriver: CronDriver | null = null
164
+
165
+ export function setCronDriver(driver: CronDriver): void {
166
+ if (driver !== "orchestrator" && driver !== "in-process") {
167
+ throw new Error(`[vibes:triggers] unknown cron driver ${JSON.stringify(driver)} — expected "orchestrator" or "in-process"`)
168
+ }
169
+ _cronDriver = driver
170
+ }
171
+
172
+ function cronDriver(): CronDriver {
173
+ if (_cronDriver) return _cronDriver
174
+ const env = (typeof process !== "undefined" ? process.env?.VIBES_CRON_DRIVER : undefined) ?? ""
175
+ if (env === "") return "orchestrator"
176
+ if (env !== "orchestrator" && env !== "in-process") {
177
+ throw new Error(`[vibes:triggers] unknown VIBES_CRON_DRIVER ${JSON.stringify(env)} — expected "orchestrator" or "in-process"`)
178
+ }
179
+ return env
180
+ }
181
+
182
+ /** Clear everything — called on HMR reload. */
183
+ export function clearTriggers(): void {
184
+ for (const interval of cronIntervals.values()) clearInterval(interval)
185
+ cronIntervals.clear()
186
+ triggerRegistry.clear()
187
+ topicSubscribers.clear()
188
+ storageHookHandlers.clear()
189
+ }
190
+
191
+ /** Load triggers from .vibes/triggers.json (called at createVibesServer boot). */
192
+ export async function loadTriggersFromFile(root: string): Promise<TriggerEntry[]> {
193
+ const triggersPath = path.join(root, ".vibes", "triggers.json")
194
+ if (!fs.existsSync(triggersPath)) return []
195
+ try {
196
+ const raw = fs.readFileSync(triggersPath, "utf-8")
197
+ const parsed = JSON.parse(raw) as TriggerEntry[]
198
+ return Array.isArray(parsed) ? parsed : []
199
+ } catch (err) {
200
+ console.error("[vibes:triggers] failed to read triggers.json:", err)
201
+ return []
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Populate the registry from a parsed trigger list. Either resolves each
207
+ * handler via dynamic import (dev) or reads it from the preloaded `mod`
208
+ * field (prod bundle). Idempotent.
209
+ */
210
+ export async function registerTriggers(entries: TriggerEntry[]): Promise<void> {
211
+ clearTriggers()
212
+ let registeredFirstTime = false
213
+ for (const e of entries) {
214
+ let fn: any
215
+ if (e.mod) {
216
+ fn = e.mod[e.exportName]
217
+ } else {
218
+ // Bun's import resolver rejected `?t=Date.now()` cache-busters on
219
+ // absolute file paths in dev (the import returned undefined exports
220
+ // silently). Plain dynamic import works the first time; rely on
221
+ // clearTriggers() + module-cache bust at HMR for re-registration.
222
+ try {
223
+ const mod = await import(e.module)
224
+ fn = mod[e.exportName]
225
+ registeredFirstTime = true
226
+ } catch (err) {
227
+ console.error(`[vibes:triggers] import ${e.module}#${e.exportName} failed:`, err)
228
+ continue
229
+ }
230
+ }
231
+ if (typeof fn !== "function") {
232
+ console.error(`[vibes:triggers] ${e.module}#${e.exportName} is not a function (got ${typeof fn}) — skipping`)
233
+ continue
234
+ }
235
+ triggerRegistry.set(e.handler, { entry: e, fn })
236
+ if (e.kind === "on") {
237
+ if (!topicSubscribers.has(e.key)) topicSubscribers.set(e.key, new Set())
238
+ topicSubscribers.get(e.key)!.add(e.handler)
239
+ } else if (e.kind === "storage:upload" || e.kind === "storage:delete") {
240
+ if (!storageHookHandlers.has(e.kind)) storageHookHandlers.set(e.kind, new Set())
241
+ storageHookHandlers.get(e.kind)!.add(e.handler)
242
+ }
243
+ }
244
+ void registeredFirstTime // suppress unused — kept for future HMR diff
245
+ const inProcessCron = vibesMode() === "dev" || cronDriver() === "in-process"
246
+ if (inProcessCron) {
247
+ scheduleAllCronInProcess()
248
+ }
249
+ console.log(
250
+ `[vibes:triggers] registered ${entries.length} trigger(s) — mode=${vibesMode()}, cron=${inProcessCron ? "in-process" : "orchestrator"}`,
251
+ )
252
+ }
253
+
254
+ /** Snapshot of the registry — used by the in-dev Inspect endpoints. */
255
+ export function listTriggers(): Array<{
256
+ handler: string
257
+ kind: "cron" | "on" | "storage:upload" | "storage:delete"
258
+ key: string
259
+ }> {
260
+ return Array.from(triggerRegistry.values()).map(({ entry }) => ({
261
+ handler: entry.handler,
262
+ kind: entry.kind,
263
+ key: entry.key,
264
+ }))
265
+ }
266
+
267
+ /**
268
+ * Fan out a storage event to every registered `storage.onUpload` /
269
+ * `onDelete` handler. Called by the auto-injected /api/_storage/notify
270
+ * route after a successful PUT/DELETE round-trips back from the browser.
271
+ *
272
+ * Handlers run in-process (no orchestrator dispatcher round-trip) — the
273
+ * notify is best-effort fire-and-forget from the SDK's perspective. A
274
+ * dropped notify means the hook doesn't fire; the storage object itself
275
+ * is unaffected.
276
+ *
277
+ * Returns the number of handlers invoked. Each handler's failure is
278
+ * logged but doesn't bubble — one bad handler shouldn't block siblings.
279
+ */
280
+ export async function dispatchStorageEvent(
281
+ kind: "storage:upload" | "storage:delete",
282
+ evt: {
283
+ key: string
284
+ size?: number
285
+ contentType?: string
286
+ userId?: string | null
287
+ scope?: "user" | "app"
288
+ },
289
+ ): Promise<number> {
290
+ const names = storageHookHandlers.get(kind)
291
+ if (!names || names.size === 0) return 0
292
+ let fired = 0
293
+ // We don't have access to ctxStore here without a circular import — the
294
+ // caller is responsible for running this inside its own ctxStore.run().
295
+ await Promise.all(Array.from(names).map(async name => {
296
+ const resolved = triggerRegistry.get(name)
297
+ if (!resolved) return
298
+ try {
299
+ // ctx is constructed by the caller via ctxStore.run() — handlers
300
+ // read `ctx.userId` from there if they need to attribute the event.
301
+ // We pass a stable payload shape that matches the @omg-dev/server
302
+ // `StorageEvent` type.
303
+ const handler = resolved.fn as (
304
+ ctx: any,
305
+ evt: any,
306
+ ) => unknown | Promise<unknown>
307
+ const payload = kind === "storage:upload"
308
+ ? { kind: "upload", key: evt.key, size: evt.size ?? 0, contentType: evt.contentType ?? "", userId: evt.userId ?? null }
309
+ : { kind: "delete", key: evt.key, userId: evt.userId ?? null }
310
+ // Pass a thin ctx-like object so handlers compiled against VibesCtx
311
+ // type still see the userId.
312
+ const ctxLike = { userId: evt.userId ?? null, system: true }
313
+ await handler(ctxLike, payload)
314
+ fired++
315
+ } catch (err) {
316
+ console.error(`[vibes:storage] handler ${name} threw:`, err)
317
+ }
318
+ }))
319
+ return fired
320
+ }
321
+
322
+ // ── Dispatch (orchestrator → handler) ───────────────────────────────────────
323
+
324
+ interface DispatchBody {
325
+ handler: string
326
+ payload?: string | null // JSON-encoded payload (events) or null (cron)
327
+ deliveryId?: string
328
+ triggerId?: string
329
+ eventId?: string
330
+ }
331
+
332
+ /**
333
+ * Invoke a registered handler. Returns a Response so the agent can pass
334
+ * it back to the orchestrator dispatcher loop, which keys retry/done on
335
+ * the status code.
336
+ */
337
+ export async function dispatchHandler(body: DispatchBody): Promise<Response> {
338
+ const resolved = triggerRegistry.get(body.handler)
339
+ if (!resolved) {
340
+ return new Response(JSON.stringify({ error: `unknown handler: ${body.handler}` }), {
341
+ status: 404,
342
+ headers: { "Content-Type": "application/json" },
343
+ })
344
+ }
345
+ let payload: unknown = null
346
+ if (body.payload && body.payload !== "null") {
347
+ try {
348
+ payload = JSON.parse(body.payload)
349
+ } catch (err) {
350
+ return new Response(JSON.stringify({ error: `payload parse: ${(err as Error).message}` }), {
351
+ status: 400,
352
+ headers: { "Content-Type": "application/json" },
353
+ })
354
+ }
355
+ }
356
+
357
+ // Run inside an empty ctx — trigger handlers have no user identity by
358
+ // default. If a handler needs to act as a specific user it can do so
359
+ // explicitly via the auth APIs.
360
+ try {
361
+ await ctxStore.run({ userId: null, system: true } as VibesCtx, async () => {
362
+ if (resolved.entry.kind === "cron") {
363
+ await (resolved.fn as CronHandler)({ userId: null, system: true } as VibesCtx)
364
+ } else {
365
+ await (resolved.fn as EventHandler)({ userId: null, system: true } as VibesCtx, payload)
366
+ }
367
+ })
368
+ return new Response(JSON.stringify({ ok: true }), {
369
+ status: 200,
370
+ headers: { "Content-Type": "application/json" },
371
+ })
372
+ } catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err)
374
+ console.error(`[vibes:triggers] handler ${body.handler} threw:`, err)
375
+ return new Response(JSON.stringify({ error: msg }), {
376
+ status: 500,
377
+ headers: { "Content-Type": "application/json" },
378
+ })
379
+ }
380
+ }
381
+
382
+ // ── Emit paths ───────────────────────────────────────────────────────────────
383
+
384
+ const MAX_EMIT_BYTES = 64 * 1024
385
+ // Track recent in-process emits so the dev Inspect view can surface them.
386
+ // Bounded ring so a long-running dev session doesn't OOM.
387
+ const DEV_EMIT_RING = 200
388
+ const recentDevEmits: Array<{
389
+ id: string
390
+ topic: string
391
+ payload: unknown
392
+ subscriberCount: number
393
+ createdAt: number
394
+ }> = []
395
+ const recentDevDeliveries: Array<{
396
+ id: string
397
+ eventId?: string
398
+ handler: string
399
+ // "pending" is for scheduled fires that haven't elapsed yet; they appear
400
+ // in the Inspect Scheduled section and flip to done/failed on fire.
401
+ status: "pending" | "done" | "failed"
402
+ attempts: number
403
+ lastError?: string
404
+ createdAt?: number
405
+ runAt: number
406
+ doneAt?: number
407
+ }> = []
408
+
409
+ function pushBounded<T>(arr: T[], v: T, max: number): void {
410
+ arr.unshift(v)
411
+ if (arr.length > max) arr.length = max
412
+ }
413
+
414
+ function emitInProcess(topic: string, payload: unknown): { subscriberCount: number } {
415
+ const payloadStr = JSON.stringify(payload ?? null)
416
+ if (payloadStr.length > MAX_EMIT_BYTES) {
417
+ throw new Error(`emit("${topic}"): payload exceeds ${MAX_EMIT_BYTES} bytes`)
418
+ }
419
+ const subs = topicSubscribers.get(topic)
420
+ const subList = subs ? Array.from(subs) : []
421
+ const ev = {
422
+ id: `evt_dev_${Date.now()}_${Math.floor(Math.random() * 4096).toString(16)}`,
423
+ topic,
424
+ payload,
425
+ subscriberCount: subList.length,
426
+ createdAt: Date.now(),
427
+ }
428
+ pushBounded(recentDevEmits, ev, DEV_EMIT_RING)
429
+
430
+ // Fire-and-forget per subscriber; record into the dev deliveries ring.
431
+ for (const handler of subList) {
432
+ const resolved = triggerRegistry.get(handler)
433
+ if (!resolved) continue
434
+ const start = Date.now()
435
+ void (async () => {
436
+ try {
437
+ await (resolved.fn as EventHandler)(
438
+ { userId: null, system: true } as VibesCtx,
439
+ payload,
440
+ )
441
+ pushBounded(recentDevDeliveries, {
442
+ id: `dlv_dev_${start}_${Math.floor(Math.random() * 4096).toString(16)}`,
443
+ eventId: ev.id,
444
+ handler,
445
+ status: "done" as const,
446
+ attempts: 1,
447
+ createdAt: start,
448
+ runAt: start,
449
+ doneAt: Date.now(),
450
+ }, DEV_EMIT_RING)
451
+ } catch (err) {
452
+ pushBounded(recentDevDeliveries, {
453
+ id: `dlv_dev_${start}_${Math.floor(Math.random() * 4096).toString(16)}`,
454
+ eventId: ev.id,
455
+ handler,
456
+ status: "failed" as const,
457
+ attempts: 1,
458
+ lastError: err instanceof Error ? err.message : String(err),
459
+ createdAt: start,
460
+ runAt: start,
461
+ doneAt: Date.now(),
462
+ }, DEV_EMIT_RING)
463
+ console.error(`[vibes:triggers] dev emit ${topic} → ${handler} threw:`, err)
464
+ }
465
+ })()
466
+ }
467
+ return { subscriberCount: subList.length }
468
+ }
469
+
470
+ async function emitToOrchestrator(topic: string, payload: unknown): Promise<{ subscriberCount: number }> {
471
+ const payloadStr = JSON.stringify(payload ?? null)
472
+ if (payloadStr.length > MAX_EMIT_BYTES) {
473
+ throw new Error(`emit("${topic}"): payload exceeds ${MAX_EMIT_BYTES} bytes`)
474
+ }
475
+ const url = "http://localhost:8080/_emit"
476
+ const res = await fetch(url, {
477
+ method: "POST",
478
+ headers: { "Content-Type": "application/json" },
479
+ body: JSON.stringify({ topic, payload: JSON.parse(payloadStr) }),
480
+ })
481
+ if (!res.ok) {
482
+ const text = await res.text().catch(() => "")
483
+ throw new Error(`emit("${topic}") agent ${res.status}: ${text.slice(0, 200)}`)
484
+ }
485
+ const body = (await res.json()) as { eventId?: string; subscriberCount?: number }
486
+ return { subscriberCount: body.subscriberCount ?? 0 }
487
+ }
488
+
489
+ // ── schedule() dev path ──────────────────────────────────────────────────────
490
+ //
491
+ // Dev mode runs entirely in-process: store the pending fire in a Map keyed
492
+ // by eventId so cancel() can find it, setTimeout to the fire moment, and
493
+ // invoke the same subscriber loop emitInProcess uses. Recorded into the
494
+ // recentDevDeliveries ring with runAt = future so the Inspect Activity tab
495
+ // shows it under "Scheduled" before it fires.
496
+
497
+ interface PendingScheduledFire {
498
+ timer: ReturnType<typeof setTimeout>
499
+ topic: string
500
+ payload: unknown
501
+ fireAt: number
502
+ // Per-subscriber delivery IDs we wrote into recentDevDeliveries with
503
+ // status=pending. cancelInProcess uses these to filter them back out.
504
+ deliveryIds: string[]
505
+ }
506
+ const pendingScheduledFires = new Map<string, PendingScheduledFire>()
507
+
508
+ function devId(prefix: string): string {
509
+ return `${prefix}_dev_${Date.now()}_${Math.floor(Math.random() * 4096).toString(16)}`
510
+ }
511
+
512
+ function scheduleInProcess(
513
+ atMs: number,
514
+ topic: string,
515
+ payload: unknown,
516
+ ): { eventId: string; subscriberCount: number; scheduledFor: number } {
517
+ const payloadStr = JSON.stringify(payload ?? null)
518
+ if (payloadStr.length > MAX_EMIT_BYTES) {
519
+ throw new Error(`schedule("${topic}"): payload exceeds ${MAX_EMIT_BYTES} bytes`)
520
+ }
521
+ const subs = topicSubscribers.get(topic)
522
+ const subList = subs ? Array.from(subs) : []
523
+ const eventId = devId("evt")
524
+ const now = Date.now()
525
+
526
+ // Record the event row so the Inspect Events list shows the scheduled
527
+ // emit immediately, even though deliveries haven't fired yet.
528
+ pushBounded(recentDevEmits, {
529
+ id: eventId,
530
+ topic,
531
+ payload,
532
+ subscriberCount: subList.length,
533
+ createdAt: now,
534
+ }, DEV_EMIT_RING)
535
+
536
+ // Pre-create the pending delivery rows so the Activity tab shows them in
537
+ // the "Scheduled" section right away. We update them in place when the
538
+ // timer fires (or remove them on cancel).
539
+ const deliveryIds: string[] = []
540
+ for (const handler of subList) {
541
+ const id = devId("dlv")
542
+ deliveryIds.push(id)
543
+ pushBounded(recentDevDeliveries, {
544
+ id,
545
+ eventId,
546
+ handler,
547
+ status: "pending" as const,
548
+ attempts: 0,
549
+ createdAt: now,
550
+ runAt: atMs,
551
+ }, DEV_EMIT_RING)
552
+ }
553
+
554
+ const delay = Math.max(0, atMs - now)
555
+ const timer = setTimeout(() => {
556
+ pendingScheduledFires.delete(eventId)
557
+ // Fire each subscriber in parallel; update the pre-recorded delivery
558
+ // entries with done/failed status.
559
+ for (let i = 0; i < subList.length; i++) {
560
+ const handler = subList[i]
561
+ const id = deliveryIds[i]
562
+ const resolved = triggerRegistry.get(handler)
563
+ if (!resolved) continue
564
+ const startedAt = Date.now()
565
+ void (async () => {
566
+ try {
567
+ await (resolved.fn as EventHandler)(
568
+ { userId: null, system: true } as VibesCtx,
569
+ payload,
570
+ )
571
+ updateDevDelivery(id, {
572
+ status: "done",
573
+ attempts: 1,
574
+ doneAt: Date.now(),
575
+ runAt: startedAt,
576
+ })
577
+ } catch (err) {
578
+ updateDevDelivery(id, {
579
+ status: "failed",
580
+ attempts: 1,
581
+ doneAt: Date.now(),
582
+ runAt: startedAt,
583
+ lastError: err instanceof Error ? err.message : String(err),
584
+ })
585
+ console.error(`[vibes:triggers] dev schedule ${topic} → ${handler} threw:`, err)
586
+ }
587
+ })()
588
+ }
589
+ }, delay)
590
+
591
+ pendingScheduledFires.set(eventId, { timer, topic, payload, fireAt: atMs, deliveryIds })
592
+
593
+ return { eventId, subscriberCount: subList.length, scheduledFor: atMs }
594
+ }
595
+
596
+ function cancelInProcess(eventId: string): { cancelled: number } {
597
+ const pending = pendingScheduledFires.get(eventId)
598
+ if (!pending) {
599
+ return { cancelled: 0 }
600
+ }
601
+ clearTimeout(pending.timer)
602
+ pendingScheduledFires.delete(eventId)
603
+ // Drop the pre-recorded pending deliveries from the ring so the
604
+ // Inspect Scheduled section doesn't keep showing them.
605
+ const ids = new Set(pending.deliveryIds)
606
+ for (let i = recentDevDeliveries.length - 1; i >= 0; i--) {
607
+ if (ids.has(recentDevDeliveries[i].id)) recentDevDeliveries.splice(i, 1)
608
+ }
609
+ return { cancelled: pending.deliveryIds.length }
610
+ }
611
+
612
+ function updateDevDelivery(
613
+ id: string,
614
+ patch: Partial<(typeof recentDevDeliveries)[number]>,
615
+ ): void {
616
+ const i = recentDevDeliveries.findIndex((d) => d.id === id)
617
+ if (i < 0) return
618
+ recentDevDeliveries[i] = { ...recentDevDeliveries[i], ...patch }
619
+ }
620
+
621
+ // ── schedule() prod path ─────────────────────────────────────────────────────
622
+
623
+ async function scheduleToOrchestrator(
624
+ atMs: number,
625
+ topic: string,
626
+ payload: unknown,
627
+ ): Promise<{ eventId: string; subscriberCount: number; scheduledFor: number }> {
628
+ const payloadStr = JSON.stringify(payload ?? null)
629
+ if (payloadStr.length > MAX_EMIT_BYTES) {
630
+ throw new Error(`schedule("${topic}"): payload exceeds ${MAX_EMIT_BYTES} bytes`)
631
+ }
632
+ const url = "http://localhost:8080/_schedule"
633
+ const res = await fetch(url, {
634
+ method: "POST",
635
+ headers: { "Content-Type": "application/json" },
636
+ body: JSON.stringify({
637
+ topic,
638
+ payload: JSON.parse(payloadStr),
639
+ runAt: atMs,
640
+ }),
641
+ })
642
+ if (!res.ok) {
643
+ const text = await res.text().catch(() => "")
644
+ throw new Error(`schedule("${topic}") agent ${res.status}: ${text.slice(0, 200)}`)
645
+ }
646
+ const body = (await res.json()) as {
647
+ eventId?: string
648
+ subscriberCount?: number
649
+ runAt?: number
650
+ }
651
+ return {
652
+ eventId: body.eventId ?? "",
653
+ subscriberCount: body.subscriberCount ?? 0,
654
+ scheduledFor: body.runAt ?? atMs,
655
+ }
656
+ }
657
+
658
+ async function cancelOnOrchestrator(eventId: string): Promise<{ cancelled: number }> {
659
+ const url = `http://localhost:8080/_schedule/${encodeURIComponent(eventId)}`
660
+ const res = await fetch(url, { method: "DELETE" })
661
+ if (!res.ok) {
662
+ const text = await res.text().catch(() => "")
663
+ throw new Error(`cancel(${eventId}) agent ${res.status}: ${text.slice(0, 200)}`)
664
+ }
665
+ const body = (await res.json()) as { cancelled?: number }
666
+ return { cancelled: body.cancelled ?? 0 }
667
+ }
668
+
669
+ // ── Dev in-process cron scheduler ────────────────────────────────────────────
670
+ //
671
+ // Schedule each cron trigger as a self-rescheduling setTimeout chain so we
672
+ // can sleep until the exact next fire instead of polling every second.
673
+ // Uses the same robfig-compatible parser via a tiny inline implementation.
674
+
675
+ function scheduleAllCronInProcess(): void {
676
+ for (const interval of cronIntervals.values()) clearInterval(interval)
677
+ cronIntervals.clear()
678
+ for (const { entry } of triggerRegistry.values()) {
679
+ if (entry.kind !== "cron") continue
680
+ scheduleOneCron(entry.handler, entry.key)
681
+ }
682
+ }
683
+
684
+ function scheduleOneCron(handlerName: string, expr: string): void {
685
+ const tickFn = async () => {
686
+ const resolved = triggerRegistry.get(handlerName)
687
+ if (!resolved) return
688
+ const start = Date.now()
689
+ try {
690
+ await (resolved.fn as CronHandler)({ userId: null, system: true } as VibesCtx)
691
+ pushBounded(recentDevDeliveries, {
692
+ id: `dlv_dev_${start}_${Math.floor(Math.random() * 4096).toString(16)}`,
693
+ handler: handlerName,
694
+ status: "done" as const,
695
+ attempts: 1,
696
+ createdAt: start,
697
+ runAt: start,
698
+ doneAt: Date.now(),
699
+ }, DEV_EMIT_RING)
700
+ } catch (err) {
701
+ pushBounded(recentDevDeliveries, {
702
+ id: `dlv_dev_${start}_${Math.floor(Math.random() * 4096).toString(16)}`,
703
+ handler: handlerName,
704
+ status: "failed" as const,
705
+ attempts: 1,
706
+ lastError: err instanceof Error ? err.message : String(err),
707
+ createdAt: start,
708
+ runAt: start,
709
+ doneAt: Date.now(),
710
+ }, DEV_EMIT_RING)
711
+ console.error(`[vibes:triggers] dev cron ${handlerName} threw:`, err)
712
+ }
713
+ }
714
+ // Compute next fire on every iteration to avoid drift. We sleep until the
715
+ // next scheduled tick rather than polling every minute.
716
+ const reschedule = () => {
717
+ const next = nextCronTime(expr, new Date())
718
+ if (!next) {
719
+ console.error(`[vibes:triggers] dev cron ${handlerName}: invalid expr ${expr}`)
720
+ return
721
+ }
722
+ const wait = Math.max(0, next.getTime() - Date.now())
723
+ const timer = setTimeout(() => {
724
+ void tickFn().finally(reschedule)
725
+ }, wait)
726
+ cronIntervals.set(handlerName, timer as unknown as ReturnType<typeof setInterval>)
727
+ }
728
+ reschedule()
729
+ }
730
+
731
+ // ── Inline cron expression evaluator ─────────────────────────────────────────
732
+ // Minimal subset of standard cron: 5 fields (minute hour dom month dow), with
733
+ // `*`, `*/N`, comma lists, ranges. Sufficient for the patterns vibes apps will
734
+ // declare. For richer expressions the orchestrator's robfig parser is the
735
+ // source of truth in prod.
736
+
737
+ function nextCronTime(expr: string, after: Date): Date | null {
738
+ try {
739
+ const fields = expr.trim().split(/\s+/)
740
+ if (fields.length !== 5) return null
741
+ const matchers = fields.map((f, i) => parseField(f, FIELD_BOUNDS[i]))
742
+ // Brute-force scan up to 4 years ahead (covers every leap-year edge).
743
+ let t = new Date(after.getTime() + 60_000 - (after.getTime() % 60_000))
744
+ const limit = t.getTime() + 4 * 365 * 24 * 60 * 60 * 1000
745
+ while (t.getTime() < limit) {
746
+ if (
747
+ matchers[0]!(t.getUTCMinutes()) &&
748
+ matchers[1]!(t.getUTCHours()) &&
749
+ matchers[2]!(t.getUTCDate()) &&
750
+ matchers[3]!(t.getUTCMonth() + 1) &&
751
+ matchers[4]!(t.getUTCDay())
752
+ ) {
753
+ return t
754
+ }
755
+ t = new Date(t.getTime() + 60_000)
756
+ }
757
+ return null
758
+ } catch {
759
+ return null
760
+ }
761
+ }
762
+
763
+ const FIELD_BOUNDS: Array<[number, number]> = [
764
+ [0, 59], // minute
765
+ [0, 23], // hour
766
+ [1, 31], // dom
767
+ [1, 12], // month
768
+ [0, 6], // dow
769
+ ]
770
+
771
+ function parseField(field: string, [lo, hi]: [number, number]): (v: number) => boolean {
772
+ const allowed = new Set<number>()
773
+ for (const part of field.split(",")) {
774
+ if (part === "*") {
775
+ for (let i = lo; i <= hi; i++) allowed.add(i)
776
+ } else if (part.includes("*/")) {
777
+ const [, stepStr] = part.split("*/")
778
+ const step = parseInt(stepStr!, 10)
779
+ if (!Number.isFinite(step) || step <= 0) continue
780
+ for (let i = lo; i <= hi; i += step) allowed.add(i)
781
+ } else if (part.includes("-")) {
782
+ const [a, b] = part.split("-").map((s) => parseInt(s, 10))
783
+ if (Number.isFinite(a!) && Number.isFinite(b!)) {
784
+ for (let i = a!; i <= b!; i++) allowed.add(i)
785
+ }
786
+ } else if (part.includes("/")) {
787
+ // a/b: starts at a, then a+b, a+2b, ...
788
+ const [aStr, bStr] = part.split("/")
789
+ const a = parseInt(aStr!, 10)
790
+ const b = parseInt(bStr!, 10)
791
+ if (Number.isFinite(a) && Number.isFinite(b) && b > 0) {
792
+ for (let i = a; i <= hi; i += b) allowed.add(i)
793
+ }
794
+ } else {
795
+ const n = parseInt(part, 10)
796
+ if (Number.isFinite(n)) allowed.add(n)
797
+ }
798
+ }
799
+ return (v: number) => allowed.has(v)
800
+ }
801
+
802
+ // ── Mode detection ───────────────────────────────────────────────────────────
803
+
804
+ let _vibesMode: "dev" | "prod" | null = null
805
+ function vibesMode(): "dev" | "prod" {
806
+ if (_vibesMode) return _vibesMode
807
+ // VIBES_MODE explicitly set by vite-plugin / deploy runtime wins.
808
+ const env = (typeof process !== "undefined" ? process.env?.VIBES_MODE : undefined) ?? ""
809
+ _vibesMode = env === "dev" ? "dev" : "prod"
810
+ return _vibesMode
811
+ }
812
+
813
+ // ── Dev Inspect data accessors ───────────────────────────────────────────────
814
+ //
815
+ // In dev mode, the dashboard's Inspect tabs read the in-memory rings
816
+ // directly via /_vibes/inspect/* routes registered on the @omg-dev/server
817
+ // instance. In prod they hit the orchestrator's sqld-backed endpoints.
818
+
819
+ export function devInspectTriggers() {
820
+ return Array.from(triggerRegistry.values()).map(({ entry }, i) => ({
821
+ id: `trg_dev_${i}_${entry.handler}`,
822
+ kind: entry.kind,
823
+ key: entry.key,
824
+ handler: entry.handler,
825
+ version: 0,
826
+ nextFireAt: 0,
827
+ createdAt: 0,
828
+ }))
829
+ }
830
+
831
+ export function devInspectDeliveries() {
832
+ return recentDevDeliveries
833
+ }
834
+
835
+ export function devInspectEvents() {
836
+ return recentDevEmits
837
+ }