@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.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
package/src/triggers.ts
ADDED
|
@@ -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
|
+
}
|