@kitlangton/motel 0.2.5 → 0.2.6

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 (60) hide show
  1. package/AGENTS.md +11 -8
  2. package/README.md +13 -2
  3. package/package.json +31 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +3 -5
  7. package/src/StartupGate.tsx +8 -10
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +103 -152
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/localServer.ts +194 -323
  16. package/src/mcp.ts +2 -1
  17. package/src/opentui-jsx.d.ts +11 -0
  18. package/src/otlp.test.ts +65 -0
  19. package/src/otlp.ts +20 -0
  20. package/src/otlpProtobuf.ts +35 -0
  21. package/src/registry.ts +37 -11
  22. package/src/runtime.ts +2 -6
  23. package/src/services/AsyncIngest.ts +20 -8
  24. package/src/services/LogQueryService.ts +11 -25
  25. package/src/services/TelemetryQuery.ts +62 -0
  26. package/src/services/TelemetryStore.ts +433 -249
  27. package/src/services/TraceQueryService.ts +18 -52
  28. package/src/services/ingestRpc.ts +2 -4
  29. package/src/services/queryRpc.ts +15 -0
  30. package/src/services/telemetryQueryWorker.ts +32 -0
  31. package/src/services/telemetryWorker.ts +5 -8
  32. package/src/storybook/aiChatStory.tsx +1 -1
  33. package/src/telemetry.test.ts +307 -41
  34. package/src/ui/AiChatView.tsx +1 -1
  35. package/src/ui/AttrFilterModal.tsx +1 -1
  36. package/src/ui/ServiceLogs.tsx +10 -7
  37. package/src/ui/SpanContentView.tsx +24 -21
  38. package/src/ui/TraceDetailsPane.tsx +1 -1
  39. package/src/ui/TraceList.tsx +1 -1
  40. package/src/ui/aiState.ts +10 -22
  41. package/src/ui/app/TraceWorkspace.tsx +2 -1
  42. package/src/ui/app/useAppLayout.ts +1 -1
  43. package/src/ui/app/useTraceScreenData.ts +22 -18
  44. package/src/ui/cachedLoader.test.ts +23 -0
  45. package/src/ui/cachedLoader.ts +60 -0
  46. package/src/ui/loaders.ts +34 -53
  47. package/src/ui/primitives.tsx +1 -1
  48. package/src/ui/state.ts +2 -0
  49. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  50. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  51. package/src/ui/traceSortNav.repro.test.ts +12 -2
  52. package/src/ui/useAttrFilterPicker.ts +10 -8
  53. package/src/ui/useKeyboardNav.ts +3 -6
  54. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  55. package/src/ui/waterfallNav.repro.test.ts +16 -8
  56. package/web/dist/assets/index-B01z9BaO.css +2 -0
  57. package/web/dist/assets/index-M86tcih5.js +22 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-DnyVo03x.js +0 -27
  60. package/web/dist/assets/index-DzuHNBGV.css +0 -2
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite"
2
2
  import { afterEach, describe, expect, test } from "bun:test"
3
3
  import { Effect } from "effect"
4
+ import rootModule from "@opentelemetry/otlp-transformer/build/esm/generated/root.js"
4
5
  import * as fs from "node:fs"
5
6
  import * as os from "node:os"
6
7
  import * as path from "node:path"
@@ -11,6 +12,16 @@ const repoRoot = path.resolve(import.meta.dir, "..")
11
12
 
12
13
  const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
13
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
+
14
25
  interface Harness {
15
26
  readonly runtimeDir: string
16
27
  readonly port: number
@@ -18,7 +29,7 @@ interface Harness {
18
29
  readonly manager: ReturnType<typeof createDaemonManager>
19
30
  }
20
31
 
21
- const makeHarness = (): Harness => {
32
+ const makeHarness = (options: { readonly startTimeoutMs?: number; readonly gracefulStopTimeoutMs?: number; readonly forceStopTimeoutMs?: number } = {}): Harness => {
22
33
  const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
23
34
  const port = randomPort()
24
35
  const databasePath = path.join(runtimeDir, "telemetry.sqlite")
@@ -27,6 +38,7 @@ const makeHarness = (): Harness => {
27
38
  runtimeDir,
28
39
  databasePath,
29
40
  port,
41
+ ...options,
30
42
  })
31
43
  return { runtimeDir, port, databasePath, manager }
32
44
  }
@@ -91,13 +103,7 @@ afterEach(async () => {
91
103
  })
92
104
 
93
105
  describe("daemon manager", () => {
94
- test("warm-start via registry is fast even when HTTP health is slow", async () => {
95
- // The failure mode we're preventing: a fully-healthy motel daemon
96
- // is alive for our cwd, but its /api/health response queues
97
- // behind heavy OTLP ingest traffic and takes >1s (seen on this
98
- // machine: /api/health taking 4s under real load). With an
99
- // HTTP-only probe the TUI would stall for seconds on every
100
- // launch; the registry-based fast path should close in <100ms.
106
+ test("does not report a registry-only daemon as healthy", async () => {
101
107
  const harness = makeHarness()
102
108
  activeHarnesses.push(harness)
103
109
 
@@ -106,13 +112,11 @@ describe("daemon manager", () => {
106
112
  const registryRoot = path.join(harness.runtimeDir, "state")
107
113
  const originalXdg = process.env.XDG_STATE_HOME
108
114
  process.env.XDG_STATE_HOME = registryRoot
109
- const registryInstancesDir = path.join(registryRoot, "motel", "instances")
115
+ const registryInstancesDir = path.join(harness.runtimeDir, "instances")
110
116
  fs.mkdirSync(registryInstancesDir, { recursive: true })
111
117
 
112
- // Seed an entry that points at THIS test process. It's alive
113
- // (we're executing), so isAlive(pid) will report true the
114
- // supervisor's fast path will adopt without ever issuing an
115
- // HTTP request.
118
+ // Seed an alive registry entry whose HTTP listener cannot answer
119
+ // within the health deadline. PID liveness must not imply readiness.
116
120
  const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
117
121
  fs.writeFileSync(entryPath, JSON.stringify({
118
122
  pid: process.pid,
@@ -123,9 +127,6 @@ describe("daemon manager", () => {
123
127
  databasePath: harness.databasePath,
124
128
  }), "utf8")
125
129
 
126
- // Park a real-but-slow listener on the port. If the supervisor
127
- // ever falls back to HTTP we'd wait out the 5s delay; a passing
128
- // test proves the fast path took over.
129
130
  const fake = startFakeDaemon({
130
131
  port: harness.port,
131
132
  databasePath: harness.databasePath,
@@ -134,15 +135,13 @@ describe("daemon manager", () => {
134
135
 
135
136
  try {
136
137
  const start = performance.now()
137
- const status = await Effect.runPromise(harness.manager.ensure)
138
+ const status = await Effect.runPromise(harness.manager.getStatus)
138
139
  const elapsed = performance.now() - start
139
- expect(status.running).toBe(true)
140
- expect(status.managed).toBe(true)
140
+ expect(status.running).toBe(false)
141
+ expect(status.managed).toBe(false)
141
142
  expect(status.pid).toBe(process.pid)
142
- // Generous — real-world is <10ms. Primarily guarding against
143
- // a future regression that silently reintroduces an HTTP probe
144
- // on the hot path.
145
- expect(elapsed).toBeLessThan(500)
143
+ expect(elapsed).toBeGreaterThan(500)
144
+ expect(elapsed).toBeLessThan(2_000)
146
145
  } finally {
147
146
  fake.stop()
148
147
  fs.rmSync(entryPath, { force: true })
@@ -151,18 +150,7 @@ describe("daemon manager", () => {
151
150
  }
152
151
  })
153
152
 
154
- test("adopts a slow-to-respond healthy daemon instead of spawning a duplicate", async () => {
155
- // Reproduces the `bun dev` EADDRINUSE flake. A real daemon is alive
156
- // and holds the port, but its /api/health response takes longer
157
- // than the supervisor's 750ms fetch timeout (e.g. the daemon is
158
- // backfilling FTS or the SQLite writer lock is held). The buggy
159
- // behaviour: supervisor thinks the port is free, spawns a fresh
160
- // daemon child, the child tries to bind() → EADDRINUSE → child
161
- // exits → supervisor throws "exited before becoming healthy".
162
- //
163
- // Correct behaviour: supervisor retries the health probe with a
164
- // longer budget before declaring the port empty, finds the
165
- // (slow) healthy motel on it, and adopts.
153
+ test("refuses to adopt a responsive but unmanaged motel server", async () => {
166
154
  const harness = makeHarness()
167
155
  activeHarnesses.push(harness)
168
156
  const fake = startFakeDaemon({
@@ -171,13 +159,46 @@ describe("daemon manager", () => {
171
159
  delayMs: 1_500,
172
160
  })
173
161
  try {
174
- const status = await Effect.runPromise(harness.manager.ensure)
175
- expect(status.running).toBe(true)
176
- expect(status.managed).toBe(true)
177
- // PID belongs to the fake test server, not a newly-spawned daemon.
178
- expect(status.pid).toBe(process.pid)
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"))
179
197
  } finally {
180
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
181
202
  }
182
203
  })
183
204
 
@@ -205,7 +226,7 @@ describe("daemon manager", () => {
205
226
  expect(finalStatus.running).toBe(false)
206
227
  })
207
228
 
208
- test("becomes healthy even if trace summary rebuild hits a write lock", async () => {
229
+ test("health responds while ingest readiness waits for a write lock", async () => {
209
230
  const harness = makeHarness()
210
231
  activeHarnesses.push(harness)
211
232
 
@@ -215,29 +236,268 @@ describe("daemon manager", () => {
215
236
 
216
237
  const locker = new Database(harness.databasePath)
217
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
+ })
218
244
  try {
219
245
  const startedAt = performance.now()
220
- const restarted = await Effect.runPromise(harness.manager.ensure)
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
+ }
221
250
  const elapsed = performance.now() - startedAt
222
- expect(restarted.running).toBe(true)
223
- expect(restarted.managed).toBe(true)
224
- expect(elapsed).toBeLessThan(10_000)
251
+ expect(response?.ok).toBe(true)
252
+ expect(elapsed).toBeLessThan(2_000)
253
+ expect(settled).toBe(false)
225
254
  } finally {
226
255
  locker.exec("ROLLBACK")
227
256
  locker.close()
228
257
  }
258
+ const restarted = await restarting
259
+ expect(restarted.running).toBe(true)
229
260
  }, 20_000)
230
261
 
231
- test("starts for the caller cwd even when motel is installed elsewhere", async () => {
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 () => {
232
486
  const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
233
- const databasePath = path.join(projectDir, ".motel-data", "telemetry.sqlite")
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
234
493
  let manager: ReturnType<typeof createDaemonManager> | null = null
494
+ const port = randomPort()
235
495
 
236
496
  try {
237
497
  await withCwd(projectDir, async () => {
238
498
  manager = createDaemonManager({
239
499
  repoRoot,
240
- port: randomPort(),
500
+ port,
241
501
  })
242
502
 
243
503
  const started = await Effect.runPromise(manager.ensure)
@@ -245,13 +505,30 @@ describe("daemon manager", () => {
245
505
  expect(started.managed).toBe(true)
246
506
  expect(started.workdir).toBe(projectDir)
247
507
  expect(started.sameWorkdir).toBe(true)
248
- expect(started.databasePath).toBe(databasePath)
249
- expect(started.logPath).toBe(path.join(projectDir, ".motel-data", "daemon.log"))
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)
250
511
 
251
512
  const reused = await Effect.runPromise(manager.ensure)
252
513
  expect(reused.pid).toBe(started.pid)
253
514
 
254
- const stopped = await Effect.runPromise(manager.stop)
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)
255
532
  expect(stopped.running).toBe(false)
256
533
  })
257
534
  } finally {
@@ -261,6 +538,10 @@ describe("daemon manager", () => {
261
538
  }
262
539
  })
263
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
264
545
  }
265
546
  })
266
547
  })