@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
@@ -0,0 +1,547 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { afterEach, describe, expect, test } from "bun:test"
3
+ import { Effect } from "effect"
4
+ import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
5
+ import * as fs from "node:fs"
6
+ import * as os from "node:os"
7
+ import * as path from "node:path"
8
+ import { createDaemonManager } from "./daemon.js"
9
+ import { MOTEL_SERVICE_ID } from "./registry.js"
10
+
11
+ const repoRoot = path.resolve(import.meta.dir, "..")
12
+
13
+ const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
14
+
15
+ const protobufRoot = rootModule as unknown as {
16
+ readonly opentelemetry: {
17
+ readonly proto: {
18
+ readonly collector: {
19
+ readonly logs: { readonly v1: { readonly ExportLogsServiceRequest: { encode: (message: unknown) => { finish: () => Uint8Array } } } }
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ interface Harness {
26
+ readonly runtimeDir: string
27
+ readonly port: number
28
+ readonly databasePath: string
29
+ readonly manager: ReturnType<typeof createDaemonManager>
30
+ }
31
+
32
+ const makeHarness = (options: { readonly startTimeoutMs?: number; readonly gracefulStopTimeoutMs?: number; readonly forceStopTimeoutMs?: number } = {}): Harness => {
33
+ const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
34
+ const port = randomPort()
35
+ const databasePath = path.join(runtimeDir, "telemetry.sqlite")
36
+ const manager = createDaemonManager({
37
+ repoRoot,
38
+ runtimeDir,
39
+ databasePath,
40
+ port,
41
+ ...options,
42
+ })
43
+ return { runtimeDir, port, databasePath, manager }
44
+ }
45
+
46
+ const withCwd = async <A>(cwd: string, f: () => Promise<A>): Promise<A> => {
47
+ const previous = process.cwd()
48
+ process.chdir(cwd)
49
+ try {
50
+ return await f()
51
+ } finally {
52
+ process.chdir(previous)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Start a motel-shaped HTTP server on a test port that answers
58
+ * /api/health with an arbitrary delay. Used to simulate a real daemon
59
+ * that's alive + holding the port but currently slow — the exact
60
+ * scenario that makes `bun dev` fail with EADDRINUSE when the
61
+ * supervisor's health probe times out and it tries to spawn a
62
+ * duplicate. Returns a stop() that releases the port.
63
+ */
64
+ const startFakeDaemon = (opts: {
65
+ readonly port: number
66
+ readonly databasePath: string
67
+ readonly delayMs: number
68
+ }) => {
69
+ const startedAt = new Date().toISOString()
70
+ const server = Bun.serve({
71
+ port: opts.port,
72
+ hostname: "127.0.0.1",
73
+ async fetch(req) {
74
+ const url = new URL(req.url)
75
+ if (url.pathname !== "/api/health") {
76
+ return new Response("not found", { status: 404 })
77
+ }
78
+ if (opts.delayMs > 0) {
79
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs))
80
+ }
81
+ return Response.json({
82
+ ok: true,
83
+ service: MOTEL_SERVICE_ID,
84
+ databasePath: opts.databasePath,
85
+ pid: process.pid,
86
+ url: `http://127.0.0.1:${opts.port}`,
87
+ workdir: process.cwd(),
88
+ startedAt,
89
+ version: "0.0.0-test",
90
+ })
91
+ },
92
+ })
93
+ return { stop: () => server.stop(true) }
94
+ }
95
+
96
+ const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
97
+
98
+ afterEach(async () => {
99
+ for (const harness of activeHarnesses.splice(0)) {
100
+ await Effect.runPromise(harness.manager.stop).catch(() => undefined)
101
+ fs.rmSync(harness.runtimeDir, { recursive: true, force: true })
102
+ }
103
+ })
104
+
105
+ describe("daemon manager", () => {
106
+ test("does not report a registry-only daemon as healthy", async () => {
107
+ const harness = makeHarness()
108
+ activeHarnesses.push(harness)
109
+
110
+ // Scope the motel registry to the harness's runtime dir so we
111
+ // neither read nor pollute the user's real ~/.local/state/motel.
112
+ const registryRoot = path.join(harness.runtimeDir, "state")
113
+ const originalXdg = process.env.XDG_STATE_HOME
114
+ process.env.XDG_STATE_HOME = registryRoot
115
+ const registryInstancesDir = path.join(harness.runtimeDir, "instances")
116
+ fs.mkdirSync(registryInstancesDir, { recursive: true })
117
+
118
+ // Seed an alive registry entry whose HTTP listener cannot answer
119
+ // within the health deadline. PID liveness must not imply readiness.
120
+ const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
121
+ fs.writeFileSync(entryPath, JSON.stringify({
122
+ pid: process.pid,
123
+ url: `http://127.0.0.1:${harness.port}`,
124
+ workdir: process.cwd(),
125
+ startedAt: new Date().toISOString(),
126
+ version: "0.0.0-test",
127
+ databasePath: harness.databasePath,
128
+ }), "utf8")
129
+
130
+ const fake = startFakeDaemon({
131
+ port: harness.port,
132
+ databasePath: harness.databasePath,
133
+ delayMs: 5_000,
134
+ })
135
+
136
+ try {
137
+ const start = performance.now()
138
+ const status = await Effect.runPromise(harness.manager.getStatus)
139
+ const elapsed = performance.now() - start
140
+ expect(status.running).toBe(false)
141
+ expect(status.managed).toBe(false)
142
+ expect(status.pid).toBe(process.pid)
143
+ expect(elapsed).toBeGreaterThan(500)
144
+ expect(elapsed).toBeLessThan(2_000)
145
+ } finally {
146
+ fake.stop()
147
+ fs.rmSync(entryPath, { force: true })
148
+ if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
149
+ else process.env.XDG_STATE_HOME = originalXdg
150
+ }
151
+ })
152
+
153
+ test("refuses to adopt a responsive but unmanaged motel server", async () => {
154
+ const harness = makeHarness()
155
+ activeHarnesses.push(harness)
156
+ const fake = startFakeDaemon({
157
+ port: harness.port,
158
+ databasePath: harness.databasePath,
159
+ delayMs: 1_500,
160
+ })
161
+ try {
162
+ await expect(Effect.runPromise(harness.manager.ensure)).rejects.toThrow("not an identity-verified managed daemon")
163
+ } finally {
164
+ fake.stop()
165
+ }
166
+ })
167
+
168
+ test("validates legacy registry entries before adopting a shared daemon", async () => {
169
+ const harness = makeHarness()
170
+ activeHarnesses.push(harness)
171
+ const stateRoot = path.join(harness.runtimeDir, "legacy-state")
172
+ const originalXdg = process.env.XDG_STATE_HOME
173
+ process.env.XDG_STATE_HOME = stateRoot
174
+ const instancesDir = path.join(harness.runtimeDir, "instances")
175
+ fs.mkdirSync(instancesDir, { recursive: true })
176
+ const entryPath = path.join(instancesDir, `${process.pid}.json`)
177
+ fs.writeFileSync(entryPath, JSON.stringify({
178
+ pid: process.pid,
179
+ url: `http://127.0.0.1:${harness.port}`,
180
+ workdir: "/tmp/legacy-project",
181
+ startedAt: new Date().toISOString(),
182
+ version: "0.0.0-legacy",
183
+ }), "utf8")
184
+
185
+ const fake = startFakeDaemon({
186
+ port: harness.port,
187
+ databasePath: path.join(harness.runtimeDir, "legacy.sqlite"),
188
+ delayMs: 0,
189
+ })
190
+
191
+ try {
192
+ const status = await Effect.runPromise(harness.manager.getStatus)
193
+ expect(status.running).toBe(false)
194
+ expect(status.managed).toBe(false)
195
+ expect(status.reason).toContain("expected")
196
+ expect(status.databasePath).toBe(path.join(harness.runtimeDir, "legacy.sqlite"))
197
+ } finally {
198
+ fake.stop()
199
+ fs.rmSync(entryPath, { force: true })
200
+ if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
201
+ else process.env.XDG_STATE_HOME = originalXdg
202
+ }
203
+ })
204
+
205
+ test("starts once, reuses the same daemon, and stops cleanly", async () => {
206
+ const harness = makeHarness()
207
+ activeHarnesses.push(harness)
208
+
209
+ const initial = await Effect.runPromise(harness.manager.getStatus)
210
+ expect(initial.running).toBe(false)
211
+
212
+ const started = await Effect.runPromise(harness.manager.ensure)
213
+ expect(started.running).toBe(true)
214
+ expect(started.managed).toBe(true)
215
+ expect(typeof started.pid).toBe("number")
216
+ expect(started.databasePath).toBe(path.join(harness.runtimeDir, "telemetry.sqlite"))
217
+
218
+ const reused = await Effect.runPromise(harness.manager.ensure)
219
+ expect(reused.running).toBe(true)
220
+ expect(reused.pid).toBe(started.pid)
221
+
222
+ const stopped = await Effect.runPromise(harness.manager.stop)
223
+ expect(stopped.running).toBe(false)
224
+
225
+ const finalStatus = await Effect.runPromise(harness.manager.getStatus)
226
+ expect(finalStatus.running).toBe(false)
227
+ })
228
+
229
+ test("health responds while ingest readiness waits for a write lock", async () => {
230
+ const harness = makeHarness()
231
+ activeHarnesses.push(harness)
232
+
233
+ const firstStart = await Effect.runPromise(harness.manager.ensure)
234
+ expect(firstStart.running).toBe(true)
235
+ await Effect.runPromise(harness.manager.stop)
236
+
237
+ const locker = new Database(harness.databasePath)
238
+ locker.exec("BEGIN IMMEDIATE")
239
+ let settled = false
240
+ const restarting = Effect.runPromise(harness.manager.ensure).then((status) => {
241
+ settled = true
242
+ return status
243
+ })
244
+ try {
245
+ const startedAt = performance.now()
246
+ let response: Response | null = null
247
+ while (performance.now() - startedAt < 2_000 && !response?.ok) {
248
+ response = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(250) }).catch(() => null)
249
+ }
250
+ const elapsed = performance.now() - startedAt
251
+ expect(response?.ok).toBe(true)
252
+ expect(elapsed).toBeLessThan(2_000)
253
+ expect(settled).toBe(false)
254
+ } finally {
255
+ locker.exec("ROLLBACK")
256
+ locker.close()
257
+ }
258
+ const restarted = await restarting
259
+ expect(restarted.running).toBe(true)
260
+ }, 20_000)
261
+
262
+ test("force-kills an identity-verified daemon that ignores graceful shutdown", async () => {
263
+ const harness = makeHarness({ gracefulStopTimeoutMs: 250, forceStopTimeoutMs: 1_000 })
264
+ activeHarnesses.push(harness)
265
+ const started = await Effect.runPromise(harness.manager.ensure)
266
+ if (started.pid === null) throw new Error("Expected managed daemon pid")
267
+ process.kill(started.pid, "SIGSTOP")
268
+
269
+ const startedAt = performance.now()
270
+ const stopped = await Effect.runPromise(harness.manager.stop)
271
+ expect(stopped.running).toBe(false)
272
+ expect(performance.now() - startedAt).toBeLessThan(2_000)
273
+ })
274
+
275
+ test("does not write response logs for ingest endpoints", async () => {
276
+ const harness = makeHarness()
277
+ activeHarnesses.push(harness)
278
+ await Effect.runPromise(harness.manager.ensure)
279
+ for (let index = 0; index < 10; index++) {
280
+ await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
281
+ method: "POST",
282
+ headers: { "content-type": "application/json" },
283
+ body: "{}",
284
+ })
285
+ }
286
+ await fetch(`http://127.0.0.1:${harness.port}/api/services`)
287
+ await Bun.sleep(100)
288
+ const log = fs.readFileSync(path.join(harness.runtimeDir, "daemon.log"), "utf8")
289
+ expect(log).not.toContain("/v1/logs")
290
+ expect(log).toContain("/api/services")
291
+ })
292
+
293
+ test("accepts OTLP protobuf log payloads", async () => {
294
+ const harness = makeHarness()
295
+ activeHarnesses.push(harness)
296
+ await Effect.runPromise(harness.manager.ensure)
297
+ const payload = protobufRoot.opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest.encode({
298
+ resourceLogs: [{
299
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "protobuf-fixture" } }] },
300
+ scopeLogs: [{ logRecords: [{
301
+ timeUnixNano: String(BigInt(Date.now()) * 1_000_000n),
302
+ severityText: "INFO",
303
+ body: { stringValue: "protobuf log" },
304
+ }] }],
305
+ }],
306
+ }).finish()
307
+ const response = await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
308
+ method: "POST",
309
+ headers: { "content-type": "application/x-protobuf" },
310
+ body: payload.buffer instanceof ArrayBuffer
311
+ ? payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
312
+ : Uint8Array.from(payload).buffer,
313
+ })
314
+ expect(response.status).toBe(200)
315
+ await Bun.sleep(100)
316
+ const probe = new Database(harness.databasePath, { readonly: true })
317
+ try {
318
+ const log = probe.query(`SELECT service_name, body FROM logs WHERE body = 'protobuf log'`).get() as { service_name: string; body: string } | null
319
+ expect(log).toEqual({ service_name: "protobuf-fixture", body: "protobuf log" })
320
+ } finally {
321
+ probe.close()
322
+ }
323
+ })
324
+
325
+ test("repeated ingest does not recursively create Motel telemetry", async () => {
326
+ const harness = makeHarness()
327
+ activeHarnesses.push(harness)
328
+ await Effect.runPromise(harness.manager.ensure)
329
+ const nowNanos = String(BigInt(Date.now()) * 1_000_000n)
330
+ const payload = JSON.stringify({
331
+ resourceLogs: [{
332
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "recursion-fixture" } }] },
333
+ scopeLogs: [{ logRecords: [{ timeUnixNano: nowNanos, body: { stringValue: "one source log" } }] }],
334
+ }],
335
+ })
336
+ for (let index = 0; index < 10; index++) {
337
+ await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, {
338
+ method: "POST",
339
+ headers: { "content-type": "application/json" },
340
+ body: index === 0 ? payload : "{}",
341
+ })
342
+ }
343
+ await Bun.sleep(250)
344
+ const probe = new Database(harness.databasePath, { readonly: true })
345
+ try {
346
+ const total = (probe.query(`SELECT COUNT(*) AS c FROM logs`).get() as { c: number }).c
347
+ const motel = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE service_name = 'motel-otel-tui'`).get() as { c: number }).c
348
+ expect(total).toBe(1)
349
+ expect(motel).toBe(0)
350
+ } finally {
351
+ probe.close()
352
+ }
353
+ })
354
+
355
+ test("large retained schema maintenance cannot block health", async () => {
356
+ const harness = makeHarness()
357
+ activeHarnesses.push(harness)
358
+ await Effect.runPromise(harness.manager.ensure)
359
+ await Effect.runPromise(harness.manager.stop)
360
+
361
+ const fixture = new Database(harness.databasePath)
362
+ fixture.exec(`DROP INDEX IF EXISTS idx_spans_service_time; DROP INDEX IF EXISTS idx_spans_trace_time; DROP INDEX IF EXISTS idx_spans_span_id; DROP INDEX IF EXISTS idx_spans_status_time; BEGIN`)
363
+ const insert = fixture.query(`INSERT INTO spans VALUES (?, ?, NULL, 'large-fixture', NULL, 'op', NULL, ?, ?, 1, 'ok', ?, '{}', '[]')`)
364
+ const blob = JSON.stringify({ blob: "x".repeat(512) })
365
+ for (let index = 0; index < 50_000; index++) insert.run(`large-trace-${index}`, `large-span-${index}`, index, index + 1, blob)
366
+ fixture.exec(`COMMIT; PRAGMA wal_checkpoint(TRUNCATE)`)
367
+ fixture.close()
368
+
369
+ const startedAt = performance.now()
370
+ const restarting = Effect.runPromise(harness.manager.ensure)
371
+ let response: Response | null = null
372
+ while (performance.now() - startedAt < 1_500 && !response?.ok) {
373
+ response = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(200) }).catch(() => null)
374
+ }
375
+ expect(response?.ok).toBe(true)
376
+ expect(performance.now() - startedAt).toBeLessThan(1_500)
377
+ const restarted = await restarting
378
+ expect(restarted.running).toBe(true)
379
+ }, 30_000)
380
+
381
+ test("an expensive retained-database query cannot block health", async () => {
382
+ const harness = makeHarness()
383
+ activeHarnesses.push(harness)
384
+ await Effect.runPromise(harness.manager.ensure)
385
+ await Effect.runPromise(harness.manager.stop)
386
+
387
+ const fixture = new Database(harness.databasePath)
388
+ const insert = fixture.query(`INSERT INTO trace_summaries VALUES (?, 'query-pressure', 'op', ?, ?, 0, ?, 1, 0)`)
389
+ const now = Date.now()
390
+ fixture.exec("BEGIN")
391
+ for (let index = 0; index < 500_000; index++) insert.run(`query-trace-${index}`, now - index, now - index + 1, index % 1_000)
392
+ fixture.exec(`COMMIT; PRAGMA wal_checkpoint(TRUNCATE)`)
393
+ fixture.close()
394
+ await Effect.runPromise(harness.manager.ensure)
395
+
396
+ const query = fetch(`http://127.0.0.1:${harness.port}/api/traces/stats?groupBy=service&agg=p95_duration&lookback=1440`)
397
+ await Bun.sleep(5)
398
+ const healthStartedAt = performance.now()
399
+ const health = await fetch(`http://127.0.0.1:${harness.port}/api/health`, { signal: AbortSignal.timeout(250) })
400
+ expect(health.ok).toBe(true)
401
+ expect(performance.now() - healthStartedAt).toBeLessThan(250)
402
+ expect((await query).ok).toBe(true)
403
+ }, 30_000)
404
+
405
+ test("configured size retention evicts oldest logs first", async () => {
406
+ const previousMax = process.env.MOTEL_OTEL_MAX_DB_SIZE_MB
407
+ const previousBatch = process.env.MOTEL_OTEL_RETENTION_LOG_BATCH
408
+ const previousInterval = process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS
409
+ process.env.MOTEL_OTEL_MAX_DB_SIZE_MB = "3"
410
+ process.env.MOTEL_OTEL_RETENTION_LOG_BATCH = "2"
411
+ process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS = "1"
412
+ const harness = makeHarness()
413
+ activeHarnesses.push(harness)
414
+ try {
415
+ await Effect.runPromise(harness.manager.ensure)
416
+ const baseNanos = BigInt(Date.now()) * 1_000_000n
417
+ for (let index = 0; index < 10; index++) {
418
+ const payload = JSON.stringify({
419
+ resourceLogs: [{
420
+ resource: { attributes: [{ key: "service.name", value: { stringValue: "size-retention" } }] },
421
+ scopeLogs: [{ logRecords: [{
422
+ timeUnixNano: String(baseNanos + BigInt(index)),
423
+ body: { stringValue: `retention-${index}-${String(index).repeat(300_000)}` },
424
+ }] }],
425
+ }],
426
+ })
427
+ await fetch(`http://127.0.0.1:${harness.port}/v1/logs`, { method: "POST", headers: { "content-type": "application/json" }, body: payload })
428
+ }
429
+
430
+ let bodies: string[] = []
431
+ const deadline = Date.now() + 6_000
432
+ while (Date.now() < deadline) {
433
+ const probe = new Database(harness.databasePath, { readonly: true })
434
+ try {
435
+ bodies = (probe.query(`SELECT body FROM logs WHERE service_name = 'size-retention' ORDER BY timestamp_ms ASC, id ASC`).all() as Array<{ body: string }>).map((row) => row.body)
436
+ } finally {
437
+ probe.close()
438
+ }
439
+ if (bodies.length < 10) break
440
+ await Bun.sleep(200)
441
+ }
442
+ expect(bodies.length).toBeLessThan(10)
443
+ expect(bodies.some((body) => body.startsWith("retention-0-"))).toBe(false)
444
+ expect(bodies.some((body) => body.startsWith("retention-9-"))).toBe(true)
445
+ } finally {
446
+ await Effect.runPromise(harness.manager.stop).catch(() => undefined)
447
+ if (previousMax === undefined) delete process.env.MOTEL_OTEL_MAX_DB_SIZE_MB
448
+ else process.env.MOTEL_OTEL_MAX_DB_SIZE_MB = previousMax
449
+ if (previousBatch === undefined) delete process.env.MOTEL_OTEL_RETENTION_LOG_BATCH
450
+ else process.env.MOTEL_OTEL_RETENTION_LOG_BATCH = previousBatch
451
+ if (previousInterval === undefined) delete process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS
452
+ else process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS = previousInterval
453
+ }
454
+ }, 20_000)
455
+
456
+ test("cleans a stale registry pid identity without signaling it", async () => {
457
+ const harness = makeHarness({ gracefulStopTimeoutMs: 100, forceStopTimeoutMs: 100 })
458
+ activeHarnesses.push(harness)
459
+ const sentinel = Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(() => {}, 1000)"], stdout: "ignore", stderr: "ignore" })
460
+ await Bun.sleep(50)
461
+ const instances = path.join(harness.runtimeDir, "instances")
462
+ fs.mkdirSync(instances, { recursive: true })
463
+ fs.writeFileSync(path.join(instances, `${sentinel.pid}.json`), JSON.stringify({
464
+ pid: sentinel.pid,
465
+ url: `http://127.0.0.1:${harness.port}`,
466
+ workdir: process.cwd(),
467
+ startedAt: new Date().toISOString(),
468
+ version: "test",
469
+ databasePath: harness.databasePath,
470
+ instanceId: "stale-instance",
471
+ processIdentity: "stale-process",
472
+ }), "utf8")
473
+
474
+ try {
475
+ const stopped = await Effect.runPromise(harness.manager.stop)
476
+ expect(stopped.running).toBe(false)
477
+ expect(fs.existsSync(path.join(instances, `${sentinel.pid}.json`))).toBe(false)
478
+ expect(sentinel.exitCode).toBeNull()
479
+ } finally {
480
+ sentinel.kill("SIGKILL")
481
+ await sentinel.exited
482
+ }
483
+ })
484
+
485
+ test("uses the shared global state dir regardless of caller cwd", async () => {
486
+ const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
487
+ const otherProjectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-other-project-")))
488
+ const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-state-"))
489
+ const expectedRuntimeDir = path.join(stateRoot, "motel")
490
+ const expectedDatabasePath = path.join(expectedRuntimeDir, "telemetry.sqlite")
491
+ const originalXdg = process.env.XDG_STATE_HOME
492
+ process.env.XDG_STATE_HOME = stateRoot
493
+ let manager: ReturnType<typeof createDaemonManager> | null = null
494
+ const port = randomPort()
495
+
496
+ try {
497
+ await withCwd(projectDir, async () => {
498
+ manager = createDaemonManager({
499
+ repoRoot,
500
+ port,
501
+ })
502
+
503
+ const started = await Effect.runPromise(manager.ensure)
504
+ expect(started.running).toBe(true)
505
+ expect(started.managed).toBe(true)
506
+ expect(started.workdir).toBe(projectDir)
507
+ expect(started.sameWorkdir).toBe(true)
508
+ expect(started.databasePath).toBe(expectedDatabasePath)
509
+ expect(started.logPath).toBe(path.join(expectedRuntimeDir, "daemon.log"))
510
+ expect(fs.existsSync(path.join(projectDir, ".motel-data"))).toBe(false)
511
+
512
+ const reused = await Effect.runPromise(manager.ensure)
513
+ expect(reused.pid).toBe(started.pid)
514
+
515
+ await withCwd(otherProjectDir, async () => {
516
+ const otherManager = createDaemonManager({
517
+ repoRoot,
518
+ port,
519
+ })
520
+ const adopted = await Effect.runPromise(otherManager.ensure)
521
+ expect(adopted.running).toBe(true)
522
+ expect(adopted.managed).toBe(true)
523
+ expect(adopted.pid).toBe(started.pid)
524
+ expect(adopted.workdir).toBe(projectDir)
525
+ expect(adopted.sameWorkdir).toBe(false)
526
+ expect(adopted.reason).toBe(null)
527
+ const stopped = await Effect.runPromise(otherManager.stop)
528
+ expect(stopped.running).toBe(false)
529
+ })
530
+
531
+ const stopped = await Effect.runPromise(manager.getStatus)
532
+ expect(stopped.running).toBe(false)
533
+ })
534
+ } finally {
535
+ await withCwd(projectDir, async () => {
536
+ if (manager) {
537
+ await Effect.runPromise(manager.stop).catch(() => undefined)
538
+ }
539
+ })
540
+ fs.rmSync(projectDir, { recursive: true, force: true })
541
+ fs.rmSync(otherProjectDir, { recursive: true, force: true })
542
+ fs.rmSync(stateRoot, { recursive: true, force: true })
543
+ if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
544
+ else process.env.XDG_STATE_HOME = originalXdg
545
+ }
546
+ })
547
+ })