@aryasaatvik/motel 0.3.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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
package/src/daemon.ts ADDED
@@ -0,0 +1,486 @@
1
+ import * as fs from "node:fs"
2
+ import { promises as fsp } from "node:fs"
3
+ import * as path from "node:path"
4
+ import { Effect } from "effect"
5
+ import { isAlive, isManagedDaemonProcess, listAliveEntries, motelStateDir, MOTEL_SERVICE_ID, processIdentity, removeRegistryEntry, type RegistryEntry } from "./registry.js"
6
+
7
+ const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
8
+ const DEFAULT_HOST = "127.0.0.1"
9
+ const DEFAULT_PORT = 27686
10
+ const START_TIMEOUT_MS = 30_000
11
+ const STOP_TIMEOUT_MS = 10_000
12
+ const LOCK_TIMEOUT_MS = 10_000
13
+ const START_POLL_INTERVAL_MS = 25
14
+ const POLL_INTERVAL_MS = 150
15
+ /** Fast probe used inside the waitForHealthy poll loop — we call it
16
+ * every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
17
+ const HEALTH_FAST_TIMEOUT_MS = 750
18
+ /** Patient probe used on critical paths: the first getStatus() call
19
+ * in ensure(), and the final pre-throw check after a spawned child
20
+ * dies. A real daemon with a busy SQLite writer (FTS backfill, big
21
+ * DB) can easily take 1-2s to answer /api/health — if we declare
22
+ * the port empty at 750ms we'll spawn a duplicate and collide with
23
+ * EADDRINUSE. 3s is long enough to tolerate a slow healthy daemon
24
+ * and short enough that a truly-down daemon is still detected
25
+ * before START_TIMEOUT_MS fires. */
26
+ const HEALTH_PATIENT_TIMEOUT_MS = 3_000
27
+ const INGEST_PROBE_TIMEOUT_MS = 3_000
28
+
29
+ type HealthShape = {
30
+ readonly ok: boolean
31
+ readonly service: string
32
+ readonly databasePath: string
33
+ readonly pid: number
34
+ readonly url: string
35
+ readonly workdir: string
36
+ readonly startedAt: string
37
+ readonly version: string
38
+ readonly instanceId?: string
39
+ }
40
+
41
+ type LockShape = {
42
+ readonly pid: number
43
+ readonly createdAt: string
44
+ readonly processIdentity?: string
45
+ }
46
+
47
+ type DaemonConfig = {
48
+ readonly repoRoot: string
49
+ readonly serverEntry: string
50
+ readonly workdir: string
51
+ readonly runtimeDir: string
52
+ readonly databasePath: string
53
+ readonly logPath: string
54
+ readonly lockPath: string
55
+ readonly host: string
56
+ readonly port: number
57
+ readonly baseUrl: string
58
+ }
59
+
60
+ export type DaemonStatus = {
61
+ readonly running: boolean
62
+ readonly managed: boolean
63
+ readonly service: string | null
64
+ readonly pid: number | null
65
+ readonly url: string
66
+ readonly databasePath: string
67
+ readonly workdir: string | null
68
+ readonly startedAt: string | null
69
+ readonly version: string | null
70
+ readonly sameWorkdir: boolean
71
+ readonly reason: string | null
72
+ readonly logPath: string
73
+ readonly lockPath: string
74
+ readonly registryPid: number | null
75
+ }
76
+
77
+ export type DaemonManager = {
78
+ readonly applyEnv: Effect.Effect<void>
79
+ readonly getStatus: Effect.Effect<DaemonStatus, DaemonError>
80
+ readonly ensure: Effect.Effect<DaemonStatus, DaemonError>
81
+ readonly stop: Effect.Effect<DaemonStatus, DaemonError>
82
+ }
83
+
84
+ type DaemonOptions = {
85
+ readonly repoRoot?: string
86
+ readonly workdir?: string
87
+ readonly runtimeDir?: string
88
+ readonly databasePath?: string
89
+ readonly host?: string
90
+ readonly port?: number
91
+ readonly startTimeoutMs?: number
92
+ readonly gracefulStopTimeoutMs?: number
93
+ readonly forceStopTimeoutMs?: number
94
+ }
95
+
96
+ export class DaemonError extends Error {
97
+ readonly _tag = "DaemonError"
98
+ constructor(message: string) {
99
+ super(message)
100
+ }
101
+ }
102
+
103
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
104
+
105
+ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
106
+ const envBaseUrl = new URL(process.env.MOTEL_OTEL_BASE_URL?.trim() || process.env.MOTEL_OTEL_QUERY_URL?.trim() || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`)
107
+ const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
108
+ const workdir = path.resolve(options.workdir ?? process.cwd())
109
+ const runtimeDir = path.resolve(options.runtimeDir ?? motelStateDir())
110
+ const databasePath = path.resolve(options.databasePath ?? process.env.MOTEL_OTEL_DB_PATH?.trim() ?? path.join(runtimeDir, "telemetry.sqlite"))
111
+ const host = options.host ?? process.env.MOTEL_OTEL_HOST?.trim() ?? envBaseUrl.hostname
112
+ const envPort = Number.parseInt(process.env.MOTEL_OTEL_PORT?.trim() || envBaseUrl.port, 10)
113
+ const port = options.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT)
114
+ return {
115
+ repoRoot,
116
+ serverEntry: path.join(repoRoot, "src/server.ts"),
117
+ workdir,
118
+ runtimeDir,
119
+ databasePath,
120
+ logPath: path.join(runtimeDir, "daemon.log"),
121
+ lockPath: path.join(runtimeDir, "daemon.lock"),
122
+ host,
123
+ port,
124
+ baseUrl: `http://${host}:${port}`,
125
+ }
126
+ }
127
+
128
+ const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
129
+ const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
130
+ const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
131
+ return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
132
+ }
133
+
134
+ const pickByUrl = (entries: readonly RegistryEntry[], baseUrl: string, databasePath: string) => {
135
+ return entries
136
+ .filter((entry) => {
137
+ return entry.url === baseUrl && (entry.databasePath === undefined || entry.databasePath === databasePath)
138
+ })
139
+ .sort((a, b) => Number(b.databasePath === databasePath) - Number(a.databasePath === databasePath))[0] ?? null
140
+ }
141
+
142
+ const expectedEnv = (config: DaemonConfig, instanceId?: string) => ({
143
+ MOTEL_RUNTIME_DIR: config.runtimeDir,
144
+ ...(instanceId ? { MOTEL_DAEMON_INSTANCE_ID: instanceId } : {}),
145
+ MOTEL_OTEL_BASE_URL: config.baseUrl,
146
+ MOTEL_OTEL_QUERY_URL: config.baseUrl,
147
+ MOTEL_OTEL_HOST: config.host,
148
+ MOTEL_OTEL_PORT: String(config.port),
149
+ MOTEL_OTEL_DB_PATH: config.databasePath,
150
+ MOTEL_OTEL_EXPORTER_URL: `${config.baseUrl}/v1/traces`,
151
+ MOTEL_OTEL_LOGS_EXPORTER_URL: `${config.baseUrl}/v1/logs`,
152
+ })
153
+
154
+ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
155
+ const config = resolveConfig(options)
156
+ const startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS
157
+ const gracefulStopTimeoutMs = options.gracefulStopTimeoutMs ?? STOP_TIMEOUT_MS
158
+ const forceStopTimeoutMs = options.forceStopTimeoutMs ?? 2_000
159
+ const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
160
+ const readRegistryEntry = () => pickByUrl(listAliveEntries(config.runtimeDir), config.baseUrl, config.databasePath)
161
+
162
+ const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
163
+ try {
164
+ const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
165
+ if (!response.ok) return null
166
+ const health = await response.json() as HealthShape
167
+ return health.ok ? health : null
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ const fetchIngestProbe = async () => {
174
+ try {
175
+ const postEmpty = (path: string) => fetch(`${config.baseUrl}${path}`, {
176
+ method: "POST",
177
+ headers: { "content-type": "application/json" },
178
+ body: "{}",
179
+ signal: AbortSignal.timeout(INGEST_PROBE_TIMEOUT_MS),
180
+ })
181
+ const [traces, logs] = await Promise.all([postEmpty("/v1/traces"), postEmpty("/v1/logs")])
182
+ return traces.ok && logs.ok
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ const describeManagedMismatch = (health: HealthShape) => {
189
+ if (health.service !== MOTEL_SERVICE_ID) {
190
+ return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
191
+ }
192
+ if (health.databasePath !== config.databasePath) {
193
+ return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
194
+ }
195
+ return null
196
+ }
197
+
198
+ const readLock = async (): Promise<LockShape | null> => {
199
+ try {
200
+ const raw = await fsp.readFile(config.lockPath, "utf8")
201
+ return JSON.parse(raw) as LockShape
202
+ } catch {
203
+ return null
204
+ }
205
+ }
206
+
207
+ const removeStaleLock = async () => {
208
+ const current = await readLock()
209
+ if (!current) {
210
+ await fsp.rm(config.lockPath, { force: true })
211
+ return true
212
+ }
213
+ if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
214
+ await fsp.rm(config.lockPath, { force: true })
215
+ return true
216
+ }
217
+
218
+ const acquireStartupLock = async () => {
219
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
220
+ await fsp.mkdir(config.runtimeDir, { recursive: true })
221
+
222
+ while (Date.now() < deadline) {
223
+ try {
224
+ const handle = await fsp.open(config.lockPath, "wx")
225
+ const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), processIdentity: processIdentity(process.pid) ?? undefined } satisfies LockShape)
226
+ await handle.writeFile(contents, "utf8")
227
+ return {
228
+ release: async () => {
229
+ await handle.close().catch(() => undefined)
230
+ await fsp.rm(config.lockPath, { force: true }).catch(() => undefined)
231
+ },
232
+ }
233
+ } catch (error) {
234
+ const errno = error as NodeJS.ErrnoException
235
+ if (errno.code !== "EEXIST") throw error
236
+ if (await removeStaleLock()) continue
237
+ await sleep(POLL_INTERVAL_MS)
238
+ }
239
+ }
240
+
241
+ throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
242
+ }
243
+
244
+ const openLogFile = async () => {
245
+ await fsp.mkdir(config.runtimeDir, { recursive: true })
246
+ return fs.openSync(config.logPath, "a")
247
+ }
248
+
249
+ const waitForHealthy = async (pid: number, instanceId: string) => {
250
+ const deadline = Date.now() + startTimeoutMs
251
+ while (Date.now() < deadline) {
252
+ const health = await fetchHealth()
253
+ if (health) {
254
+ const mismatch = describeManagedMismatch(health)
255
+ const registry = readRegistryEntry()
256
+ if (!mismatch && health.pid === pid && health.instanceId === instanceId && registry?.pid === pid && registry.instanceId === instanceId && isManagedDaemonProcess(registry) && await fetchIngestProbe()) return health
257
+ if (mismatch) throw new Error(mismatch)
258
+ }
259
+ if (!isAlive(pid)) {
260
+ throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
261
+ }
262
+ await sleep(START_POLL_INTERVAL_MS)
263
+ }
264
+ throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
265
+ }
266
+
267
+ const waitUntilNotOwned = async (entry: RegistryEntry, timeoutMs: number) => {
268
+ const deadline = Date.now() + timeoutMs
269
+ while (Date.now() < deadline) {
270
+ if (!isManagedDaemonProcess(entry)) return true
271
+ await sleep(POLL_INTERVAL_MS)
272
+ }
273
+ return !isManagedDaemonProcess(entry)
274
+ }
275
+
276
+ const stopPid = async (entry: RegistryEntry) => {
277
+ if (!isManagedDaemonProcess(entry)) {
278
+ throw new Error(`Refusing to stop pid ${entry.pid}: registry identity does not match the running managed daemon.`)
279
+ }
280
+ try {
281
+ process.kill(entry.pid, "SIGTERM")
282
+ } catch (error) {
283
+ const errno = error as NodeJS.ErrnoException
284
+ if (errno.code !== "ESRCH") throw error
285
+ }
286
+
287
+ if (!await waitUntilNotOwned(entry, gracefulStopTimeoutMs)) {
288
+ try {
289
+ process.kill(entry.pid, "SIGKILL")
290
+ } catch (error) {
291
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error
292
+ }
293
+ if (!await waitUntilNotOwned(entry, forceStopTimeoutMs)) {
294
+ throw new Error(`Timed out force-killing daemon ${entry.pid}.`)
295
+ }
296
+ }
297
+ const current = readRegistryEntry()
298
+ if (current?.pid === entry.pid && current.instanceId === entry.instanceId) removeRegistryEntry(entry.pid, config.runtimeDir)
299
+ }
300
+
301
+ const getStatus = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<DaemonStatus> => {
302
+ const registry = readRegistryEntry()
303
+ const health = await fetchHealth(timeoutMs)
304
+ if (!health) {
305
+ return {
306
+ running: false,
307
+ managed: false,
308
+ service: null,
309
+ pid: registry?.pid ?? null,
310
+ url: config.baseUrl,
311
+ databasePath: config.databasePath,
312
+ workdir: registry?.workdir ?? null,
313
+ startedAt: registry?.startedAt ?? null,
314
+ version: registry?.version ?? null,
315
+ sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
316
+ reason: registry ? "Registry entry exists but daemon is not healthy." : null,
317
+ logPath: config.logPath,
318
+ lockPath: config.lockPath,
319
+ registryPid: registry?.pid ?? null,
320
+ }
321
+ }
322
+
323
+ const mismatch = describeManagedMismatch(health)
324
+ const managed = mismatch === null && registry?.pid === health.pid && registry.instanceId === health.instanceId && isManagedDaemonProcess(registry)
325
+ return {
326
+ running: mismatch === null,
327
+ managed,
328
+ service: health.service,
329
+ pid: health.pid,
330
+ url: health.url,
331
+ databasePath: health.databasePath,
332
+ workdir: health.workdir,
333
+ startedAt: health.startedAt,
334
+ version: health.version,
335
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
336
+ reason: mismatch ?? (managed ? null : "Responsive motel server is not an identity-verified managed daemon."),
337
+ logPath: config.logPath,
338
+ lockPath: config.lockPath,
339
+ registryPid: registry?.pid ?? null,
340
+ }
341
+ }
342
+
343
+ const ensure = async (): Promise<DaemonStatus> => {
344
+ // Use the patient timeout for the initial probe — this is the
345
+ // critical "is there already a daemon here?" check. A false
346
+ // negative here drops us into the spawn path and collides with
347
+ // any slow-but-healthy daemon sitting on the port.
348
+ const existing = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
349
+ const existingEntry = readRegistryEntry()
350
+ if (existing.managed && existing.running) {
351
+ // /api/health can stay healthy after the lazy ingest worker/RPC path
352
+ // has been poisoned by an interrupted request. Empty OTLP posts are
353
+ // side-effect free and exercise the same path real exporters need.
354
+ if (existing.pid === process.pid || await fetchIngestProbe()) return existing
355
+ if (existingEntry) await stopPid(existingEntry)
356
+ }
357
+ if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) await stopPid(existingEntry)
358
+ if (existing.service !== null && existing.reason) {
359
+ throw new Error(existing.reason)
360
+ }
361
+
362
+ const lock = await acquireStartupLock()
363
+ let spawnedPid: number | null = null
364
+ let spawnedIdentity: string | null = null
365
+ try {
366
+ // Same reasoning for the post-lock re-check: another ensure()
367
+ // may have spawned a daemon between our first probe and the
368
+ // lock grant, and its initial health response can be slow
369
+ // while the runtime warms up.
370
+ const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
371
+ if (rechecked.managed && rechecked.running) {
372
+ if (rechecked.pid === process.pid || await fetchIngestProbe()) return rechecked
373
+ const recheckedEntry = readRegistryEntry()
374
+ if (recheckedEntry) await stopPid(recheckedEntry)
375
+ }
376
+ if (rechecked.service !== null && rechecked.reason) {
377
+ throw new Error(rechecked.reason)
378
+ }
379
+
380
+ const logFd = await openLogFile()
381
+ const instanceId = crypto.randomUUID()
382
+ try {
383
+ const proc = Bun.spawn({
384
+ cmd: [process.execPath, "run", config.serverEntry],
385
+ cwd: config.workdir,
386
+ detached: true,
387
+ env: {
388
+ ...process.env,
389
+ ...expectedEnv(config, instanceId),
390
+ },
391
+ stdio: ["ignore", logFd, logFd],
392
+ })
393
+ spawnedPid = proc.pid
394
+ spawnedIdentity = processIdentity(proc.pid)
395
+ proc.unref()
396
+ } finally {
397
+ fs.closeSync(logFd)
398
+ }
399
+
400
+ if (spawnedPid === null) {
401
+ throw new Error("Daemon failed to spawn.")
402
+ }
403
+
404
+ const health = await waitForHealthy(spawnedPid, instanceId)
405
+ return {
406
+ running: true,
407
+ managed: true,
408
+ service: health.service,
409
+ pid: health.pid,
410
+ url: health.url,
411
+ databasePath: health.databasePath,
412
+ workdir: health.workdir,
413
+ startedAt: health.startedAt,
414
+ version: health.version,
415
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
416
+ reason: null,
417
+ logPath: config.logPath,
418
+ lockPath: config.lockPath,
419
+ registryPid: health.pid,
420
+ }
421
+ } catch (error) {
422
+ if (spawnedPid !== null) {
423
+ const entry = readRegistryEntry()
424
+ if (entry?.pid === spawnedPid) {
425
+ await stopPid(entry).catch(() => undefined)
426
+ } else if (spawnedIdentity && processIdentity(spawnedPid) === spawnedIdentity) {
427
+ try { process.kill(spawnedPid, "SIGTERM") } catch { /* already exited */ }
428
+ const deadline = Date.now() + gracefulStopTimeoutMs
429
+ while (Date.now() < deadline && processIdentity(spawnedPid) === spawnedIdentity) await sleep(POLL_INTERVAL_MS)
430
+ if (processIdentity(spawnedPid) === spawnedIdentity) {
431
+ try { process.kill(spawnedPid, "SIGKILL") } catch { /* already exited */ }
432
+ }
433
+ }
434
+ }
435
+ throw error
436
+ } finally {
437
+ await lock.release()
438
+ }
439
+ }
440
+
441
+ const stop = async (): Promise<DaemonStatus> => {
442
+ const status = await getStatus()
443
+ if (status.pid === null) return status
444
+ if (status.service !== null && status.service !== MOTEL_SERVICE_ID) {
445
+ throw new Error(`Refusing to stop non-motel service ${status.service} on ${status.url}.`)
446
+ }
447
+ const entry = readRegistryEntry()
448
+ if (!entry || entry.pid !== status.pid) throw new Error(`Refusing to stop pid ${status.pid}: no matching managed registry entry.`)
449
+ await stopPid(entry)
450
+ return await getStatus()
451
+ }
452
+
453
+ return {
454
+ applyEnv: Effect.sync(() => {
455
+ for (const [key, value] of Object.entries(expectedEnv(config))) {
456
+ process.env[key] = value
457
+ }
458
+ }),
459
+ getStatus: Effect.fn("DaemonManager.getStatus")(() =>
460
+ Effect.tryPromise({
461
+ // Wrapped so Effect.tryPromise only sees the no-arg call
462
+ // signature — the optional timeoutMs parameter is an
463
+ // internal detail used by ensure()'s critical probes.
464
+ try: () => getStatus(),
465
+ catch: mapError,
466
+ }),
467
+ )(),
468
+ ensure: Effect.fn("DaemonManager.ensure")(() =>
469
+ Effect.tryPromise({
470
+ try: ensure,
471
+ catch: mapError,
472
+ }),
473
+ )(),
474
+ stop: Effect.fn("DaemonManager.stop")(() =>
475
+ Effect.tryPromise({
476
+ try: stop,
477
+ catch: mapError,
478
+ }),
479
+ )(),
480
+ }
481
+ }
482
+
483
+ export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
484
+ export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
485
+ export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
486
+ export const stopManagedDaemon = Effect.suspend(() => createDaemonManager().stop)
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { AI_FTS_KEYS, isAiSpan } from "./domain.ts"
3
+
4
+ describe("isAiSpan", () => {
5
+ test("returns false for empty tags", () => {
6
+ expect(isAiSpan({})).toBe(false)
7
+ })
8
+
9
+ test("returns false when no AI key is present", () => {
10
+ expect(isAiSpan({
11
+ "service.name": "web",
12
+ "http.method": "GET",
13
+ "db.statement": "SELECT 1",
14
+ })).toBe(false)
15
+ })
16
+
17
+ test("detects Vercel AI SDK keys", () => {
18
+ expect(isAiSpan({ "ai.prompt.messages": "[]" })).toBe(true)
19
+ expect(isAiSpan({ "ai.response.text": "hi" })).toBe(true)
20
+ expect(isAiSpan({ "ai.toolCall.args": "{}" })).toBe(true)
21
+ })
22
+
23
+ test("detects OpenTelemetry gen_ai semconv keys", () => {
24
+ expect(isAiSpan({ "gen_ai.prompt": "foo" })).toBe(true)
25
+ expect(isAiSpan({ "gen_ai.input.messages": "[]" })).toBe(true)
26
+ expect(isAiSpan({ "gen_ai.tool.definitions": "[]" })).toBe(true)
27
+ })
28
+
29
+ test("detects OpenInference keys", () => {
30
+ expect(isAiSpan({ "input.value": "hi" })).toBe(true)
31
+ expect(isAiSpan({ "output.value": "hi" })).toBe(true)
32
+ })
33
+
34
+ test("detects a single AI key among many non-AI keys", () => {
35
+ expect(isAiSpan({
36
+ "service.name": "web",
37
+ "http.method": "POST",
38
+ "http.status_code": "200",
39
+ "ai.model.id": "ignored-not-in-fts-keys",
40
+ "ai.prompt": "tell me a joke",
41
+ })).toBe(true)
42
+ })
43
+
44
+ test("ignores AI-adjacent keys that are not in the FTS set", () => {
45
+ // `ai.model.provider`, `ai.settings.*`, `ai.telemetry.*` carry
46
+ // metadata, not content, so they intentionally aren't part of
47
+ // AI_FTS_KEYS. A span with ONLY those should not be flagged.
48
+ expect(isAiSpan({
49
+ "ai.model.provider": "openai",
50
+ "ai.model.id": "gpt-4",
51
+ "ai.settings.maxRetries": "2",
52
+ })).toBe(false)
53
+ })
54
+
55
+ test("every documented key triggers detection", () => {
56
+ // Guard against a future reshuffle of AI_FTS_KEYS that might
57
+ // drop a key silently — every declared key should round-trip.
58
+ for (const key of AI_FTS_KEYS) {
59
+ expect(isAiSpan({ [key]: "payload" })).toBe(true)
60
+ }
61
+ })
62
+ })