@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
package/AGENTS.md CHANGED
@@ -28,6 +28,11 @@
28
28
  - Build the web UI: `bun run web:build`
29
29
  - Dev the web UI (with hot reload): `bun run web:dev`
30
30
  - Typecheck: `bun run typecheck`
31
+ - Effect LSP diagnostics over the whole project: `bunx effect-language-service diagnostics --project tsconfig.json --format text`
32
+ - Effect LSP interactive setup wizard: `bunx effect-language-service setup`
33
+
34
+ ## Effect LSP
35
+ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugins` entry. Editors that pick up the TypeScript workspace plugin (Zed, VSCode, Cursor, NVim via vtsls) will surface Effect-specific diagnostics, quick fixes, and refactors inline. In Zed this requires selecting the workspace TypeScript version — it does so automatically when `node_modules/typescript` is present.
31
36
 
32
37
  ## Verification
33
38
  - The built-in verification step is `bun run typecheck`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "scripts": {
47
47
  "clear-motel-debug": "bun run skills/motel-debug/scripts/clear-motel-debug.ts",
48
- "dev": "bun run src/motel.ts tui",
48
+ "dev": "MOTEL_OTEL_ENABLED=true bun run src/motel.ts tui",
49
49
  "start": "bun run src/motel.ts tui",
50
50
  "daemon": "bun run src/motel.ts daemon",
51
51
  "status": "bun run src/motel.ts status",
@@ -65,12 +65,14 @@
65
65
  "search-spans": "bun run src/cli.ts search-spans",
66
66
  "trace-stats": "bun run src/cli.ts trace-stats",
67
67
  "log-stats": "bun run src/cli.ts log-stats",
68
- "web:dev": "cd web && npx vite",
69
- "web:build": "cd web && npx vite build",
68
+ "web:dev": "bun run --cwd web dev",
69
+ "web:build": "bun run --cwd web build",
70
+ "story:chat": "bun run src/storybook/aiChatStory.tsx",
70
71
  "typecheck": "tsc --noEmit",
71
72
  "prepublishOnly": "bun run web:build"
72
73
  },
73
74
  "devDependencies": {
75
+ "@effect/language-service": "^0.85.1",
74
76
  "@types/bun": "^1.3.12",
75
77
  "@types/react": "^19.2.14",
76
78
  "typescript": "^6.0.2"
@@ -78,7 +80,7 @@
78
80
  "dependencies": {
79
81
  "@effect/atom-react": "^4.0.0-beta.49",
80
82
  "@effect/opentelemetry": "^4.0.0-beta.49",
81
- "@effect/platform-bun": "^4.0.0-beta.49",
83
+ "@effect/platform-bun": "^4.0.0-beta.50",
82
84
  "@opentelemetry/api": "^1.9.0",
83
85
  "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
84
86
  "@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
package/src/App.tsx CHANGED
@@ -8,21 +8,173 @@ import { useAppLayout } from "./ui/app/useAppLayout.ts"
8
8
  import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
9
9
  import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
10
10
  import {
11
+ type AttrFacetState,
11
12
  attrPickerIndexAtom,
12
13
  attrPickerInputAtom,
13
14
  attrPickerModeAtom,
14
15
  attrFacetStateAtom,
16
+ chatDetailChunkIdAtom,
17
+ chatDetailScrollOffsetAtom,
15
18
  noticeAtom,
16
19
  persistSelectedTheme,
20
+ selectedAttrIndexAtom,
21
+ selectedChatChunkIdAtom,
17
22
  selectedThemeAtom,
18
23
  waterfallFilterModeAtom,
19
24
  waterfallFilterTextAtom,
20
25
  } from "./ui/state.ts"
26
+ import type { ThemeName } from "./ui/theme.ts"
21
27
  import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
22
- import { getVisibleSpans } from "./ui/Waterfall.tsx"
23
28
  import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
24
29
  import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
25
30
  import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
31
+ import { getVisibleSpans } from "./ui/waterfallModel.ts"
32
+
33
+ const NOTICE_TIMEOUT_MS = 2500
34
+
35
+ const buildHeaderModel = ({
36
+ headerFooterWidth,
37
+ selectedTraceService,
38
+ activeAttrKey,
39
+ activeAttrValue,
40
+ autoRefresh,
41
+ fetchedAt,
42
+ status,
43
+ }: {
44
+ readonly headerFooterWidth: number
45
+ readonly selectedTraceService: string | null
46
+ readonly activeAttrKey: string | null
47
+ readonly activeAttrValue: string | null
48
+ readonly autoRefresh: boolean
49
+ readonly fetchedAt: Date | null
50
+ readonly status: string
51
+ }) => {
52
+ const serviceLabel = selectedTraceService ?? "none"
53
+ const autoLabel = autoRefresh ? "● live" : "○ paused"
54
+ const attrFilterLabel = activeAttrKey && activeAttrValue
55
+ ? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
56
+ : ""
57
+ const right = fetchedAt
58
+ ? `${autoLabel} ${formatTimestamp(fetchedAt)}`
59
+ : status === "loading"
60
+ ? "loading traces..."
61
+ : ""
62
+ const leftLength = "MOTEL".length + SEPARATOR.length + serviceLabel.length + attrFilterLabel.length
63
+ const gap = Math.max(2, headerFooterWidth - leftLength - right.length)
64
+
65
+ return {
66
+ serviceLabel,
67
+ attrFilterLabel,
68
+ right,
69
+ gap,
70
+ } as const
71
+ }
72
+
73
+ const AppHeader = ({
74
+ serviceLabel,
75
+ attrFilterLabel,
76
+ gap,
77
+ right,
78
+ }: {
79
+ readonly serviceLabel: string
80
+ readonly attrFilterLabel: string
81
+ readonly gap: number
82
+ readonly right: string
83
+ }) => (
84
+ <box paddingLeft={1} paddingRight={1} flexDirection="column">
85
+ <TextLine>
86
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
87
+ <span fg={colors.separator}>{SEPARATOR}</span>
88
+ <span fg={colors.muted}>{serviceLabel}</span>
89
+ {attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
90
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
91
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>{right}</span>
92
+ </TextLine>
93
+ </box>
94
+ )
95
+
96
+ const AppFooter = ({
97
+ showSplit,
98
+ footerHeight,
99
+ contentWidth,
100
+ leftPaneWidth,
101
+ rightPaneWidth,
102
+ footerNotice,
103
+ spanNavActive,
104
+ detailView,
105
+ autoRefresh,
106
+ headerFooterWidth,
107
+ }: {
108
+ readonly showSplit: boolean
109
+ readonly footerHeight: number
110
+ readonly contentWidth: number
111
+ readonly leftPaneWidth: number
112
+ readonly rightPaneWidth: number
113
+ readonly footerNotice: string | null
114
+ readonly spanNavActive: boolean
115
+ readonly detailView: "waterfall" | "span-detail" | "service-logs"
116
+ readonly autoRefresh: boolean
117
+ readonly headerFooterWidth: number
118
+ }) => {
119
+ if (footerHeight <= 0) return null
120
+
121
+ return (
122
+ <>
123
+ {showSplit
124
+ ? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
125
+ : <Divider width={contentWidth} />}
126
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
127
+ {footerNotice ? (
128
+ <PlainLine text={footerNotice} fg={colors.count} />
129
+ ) : (
130
+ <FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
131
+ )}
132
+ </box>
133
+ </>
134
+ )
135
+ }
136
+
137
+ const AppOverlays = ({
138
+ width,
139
+ height,
140
+ showHelp,
141
+ autoRefresh,
142
+ selectedTheme,
143
+ setShowHelp,
144
+ pickerMode,
145
+ pickerInput,
146
+ pickerIndex,
147
+ activeAttrKey,
148
+ attrFacets,
149
+ }: {
150
+ readonly width: number
151
+ readonly height: number
152
+ readonly showHelp: boolean
153
+ readonly autoRefresh: boolean
154
+ readonly selectedTheme: ThemeName
155
+ readonly setShowHelp: (value: boolean) => void
156
+ readonly pickerMode: "off" | "keys" | "values"
157
+ readonly pickerInput: string
158
+ readonly pickerIndex: number
159
+ readonly activeAttrKey: string | null
160
+ readonly attrFacets: AttrFacetState
161
+ }) => (
162
+ <>
163
+ {showHelp ? <HelpModal width={width} height={height} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
164
+ {pickerMode !== "off" ? (
165
+ <AttrFilterModal
166
+ width={width}
167
+ height={height}
168
+ mode={pickerMode}
169
+ input={pickerInput}
170
+ selectedIndex={pickerIndex}
171
+ selectedKey={activeAttrKey}
172
+ state={attrFacets}
173
+ onClose={() => { /* handled via keyboard */ }}
174
+ />
175
+ ) : null}
176
+ </>
177
+ )
26
178
 
27
179
  export const App = () => {
28
180
  const { width, height } = useTerminalDimensions()
@@ -54,6 +206,8 @@ export const App = () => {
54
206
  selectedTraceSummary,
55
207
  selectedTrace,
56
208
  filteredTraces,
209
+ aiCallDetailState,
210
+ aiChatChunks,
57
211
  } = useTraceScreenData()
58
212
  const [pickerMode] = useAtom(attrPickerModeAtom)
59
213
  const [pickerInput] = useAtom(attrPickerInputAtom)
@@ -61,6 +215,10 @@ export const App = () => {
61
215
  const [attrFacets] = useAtom(attrFacetStateAtom)
62
216
  const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
63
217
  const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
218
+ const [selectedAttrIndex] = useAtom(selectedAttrIndexAtom)
219
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
220
+ const [chatDetailChunkId, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
221
+ const [chatDetailScrollOffset, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
64
222
  useAttrFilterPicker(activeAttrKey)
65
223
 
66
224
  const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
@@ -90,7 +248,7 @@ export const App = () => {
90
248
  setNotice(message)
91
249
  noticeTimeoutRef.current = globalThis.setTimeout(() => {
92
250
  setNotice((current) => (current === message ? null : current))
93
- }, 2500)
251
+ }, NOTICE_TIMEOUT_MS)
94
252
  }
95
253
 
96
254
  useEffect(() => () => {
@@ -106,6 +264,7 @@ export const App = () => {
106
264
  const { spanNavActive } = useKeyboardNav({
107
265
  selectedTrace,
108
266
  filteredTraces,
267
+ aiChatChunks,
109
268
  isWideLayout,
110
269
  wideBodyLines,
111
270
  narrowBodyLines,
@@ -114,30 +273,41 @@ export const App = () => {
114
273
  flashNotice,
115
274
  })
116
275
 
117
- const headerServiceLabel = selectedTraceService ?? "none"
118
- const autoLabel = autoRefresh ? "● live" : "○ paused"
119
- const attrFilterLabel = activeAttrKey && activeAttrValue
120
- ? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
121
- : ""
122
- const headerRight = traceState.fetchedAt
123
- ? `${autoLabel} ${formatTimestamp(traceState.fetchedAt)}`
124
- : traceState.status === "loading"
125
- ? "loading traces..."
126
- : ""
127
- const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length + attrFilterLabel.length
128
- const headerGap = Math.max(2, headerFooterWidth - headerLeftLen - headerRight.length)
129
- const visibleFooterNotice = footerNotice
276
+ const headerModel = buildHeaderModel({
277
+ headerFooterWidth,
278
+ selectedTraceService,
279
+ activeAttrKey,
280
+ activeAttrValue,
281
+ autoRefresh,
282
+ fetchedAt: traceState.fetchedAt,
283
+ status: traceState.status,
284
+ })
130
285
 
131
286
  const selectTraceById = useCallback((traceId: string) => {
132
287
  const index = traceState.data.findIndex((trace) => trace.traceId === traceId)
133
288
  if (index >= 0) setSelectedTraceIndex(index)
134
289
  }, [setSelectedTraceIndex, traceState.data])
135
290
 
291
+ const visibleSpans = useMemo(
292
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
293
+ [selectedTrace, collapsedSpanIds],
294
+ )
295
+
296
+ const openChatChunkDetail = useCallback((chunkId: string) => {
297
+ setSelectedChatChunkId(chunkId)
298
+ setChatDetailChunkId(chunkId)
299
+ setChatDetailScrollOffset(0)
300
+ }, [setSelectedChatChunkId, setChatDetailChunkId, setChatDetailScrollOffset])
301
+
302
+ const closeChatChunkDetail = useCallback(() => {
303
+ setChatDetailChunkId(null)
304
+ setChatDetailScrollOffset(0)
305
+ }, [setChatDetailChunkId, setChatDetailScrollOffset])
306
+
136
307
  const selectSpan = useCallback((index: number) => {
137
- if (!selectedTrace) return
138
- const visibleCount = getVisibleSpans(selectedTrace.spans, collapsedSpanIds).length
139
- setSelectedSpanIndex(Math.max(0, Math.min(index, visibleCount - 1)))
140
- }, [collapsedSpanIds, selectedTrace, setSelectedSpanIndex])
308
+ if (visibleSpans.length === 0) return
309
+ setSelectedSpanIndex(Math.max(0, Math.min(index, visibleSpans.length - 1)))
310
+ }, [setSelectedSpanIndex, visibleSpans])
141
311
 
142
312
  const traceListProps = useMemo(() => ({
143
313
  traces: filteredTraces,
@@ -154,27 +324,23 @@ export const App = () => {
154
324
  onSelectTrace: selectTraceById,
155
325
  } as const), [filteredTraces, selectedTraceSummary?.traceId, traceState.status, traceState.error, leftContentWidth, traceState.services, selectedTraceService, spanNavActive, filterText, traceSort, traceState.data.length, selectTraceById])
156
326
 
157
- const filteredSpans = selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : []
158
- const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
327
+ const selectedSpan = selectedSpanIndex !== null ? visibleSpans[selectedSpanIndex] ?? null : null
159
328
  const selectedSpanLogs = useMemo(
160
329
  () => selectedSpan ? logState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
161
330
  [selectedSpan, logState.data],
162
331
  )
163
332
 
164
- const showSplit = isWideLayout
333
+ // Top/bottom frame dividers only render junction glyphs (`┬` / `┴`)
334
+ // when there's actually a vertical SeparatorColumn in the workspace
335
+ // below/above them to meet. Service-logs view is wide but single-pane,
336
+ // so its frame dividers must be plain — otherwise the junction floats
337
+ // above an empty column and leaves a visible stale sliver when
338
+ // toggling tab back and forth with the trace view.
339
+ const showSplit = isWideLayout && detailView !== "service-logs"
165
340
 
166
341
  return (
167
342
  <box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
168
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
169
- <TextLine>
170
- <span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
171
- <span fg={colors.separator}>{SEPARATOR}</span>
172
- <span fg={colors.muted}>{headerServiceLabel}</span>
173
- {attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
174
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
175
- <span fg={colors.muted} attributes={TextAttributes.BOLD}>{headerRight}</span>
176
- </TextLine>
177
- </box>
343
+ <AppHeader {...headerModel} />
178
344
  {showSplit
179
345
  ? <SplitDivider leftWidth={leftPaneWidth} junction={"┬"} rightWidth={rightPaneWidth} />
180
346
  : <Divider width={contentWidth} />}
@@ -199,35 +365,43 @@ export const App = () => {
199
365
  viewLevel={viewLevel}
200
366
  selectedSpan={selectedSpan}
201
367
  selectedSpanLogs={selectedSpanLogs}
368
+ selectedAttrIndex={selectedAttrIndex}
369
+ aiCallDetailState={aiCallDetailState}
370
+ aiChatChunks={aiChatChunks}
371
+ selectedChatChunkId={selectedChatChunkId}
372
+ onSelectChatChunk={setSelectedChatChunkId}
373
+ chatDetailChunkId={chatDetailChunkId}
374
+ onOpenChatChunkDetail={openChatChunkDetail}
375
+ onCloseChatChunkDetail={closeChatChunkDetail}
376
+ chatDetailScrollOffset={chatDetailScrollOffset}
377
+ onSetChatDetailScrollOffset={(updater) => setChatDetailScrollOffset(updater)}
202
378
  selectSpan={selectSpan}
203
379
  />
204
- {footerHeight > 0 ? (
205
- <>
206
- {showSplit
207
- ? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
208
- : <Divider width={contentWidth} />}
209
- <box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
210
- {visibleFooterNotice ? (
211
- <PlainLine text={visibleFooterNotice} fg={colors.count} />
212
- ) : (
213
- <FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
214
- )}
215
- </box>
216
- </>
217
- ) : null}
218
- {showHelp ? <HelpModal width={width ?? 100} height={height ?? 24} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
219
- {pickerMode !== "off" ? (
220
- <AttrFilterModal
221
- width={width ?? 100}
222
- height={height ?? 24}
223
- mode={pickerMode}
224
- input={pickerInput}
225
- selectedIndex={pickerIndex}
226
- selectedKey={activeAttrKey}
227
- state={attrFacets}
228
- onClose={() => { /* handled via keyboard */ }}
229
- />
230
- ) : null}
380
+ <AppFooter
381
+ showSplit={showSplit}
382
+ footerHeight={footerHeight}
383
+ contentWidth={contentWidth}
384
+ leftPaneWidth={leftPaneWidth}
385
+ rightPaneWidth={rightPaneWidth}
386
+ footerNotice={footerNotice}
387
+ spanNavActive={spanNavActive}
388
+ detailView={detailView}
389
+ autoRefresh={autoRefresh}
390
+ headerFooterWidth={headerFooterWidth}
391
+ />
392
+ <AppOverlays
393
+ width={width ?? 100}
394
+ height={height ?? 24}
395
+ showHelp={showHelp}
396
+ autoRefresh={autoRefresh}
397
+ selectedTheme={selectedTheme}
398
+ setShowHelp={setShowHelp}
399
+ pickerMode={pickerMode}
400
+ pickerInput={pickerInput}
401
+ pickerIndex={pickerIndex}
402
+ activeAttrKey={activeAttrKey}
403
+ attrFacets={attrFacets}
404
+ />
231
405
  </box>
232
406
  )
233
407
  }
@@ -1,28 +1,86 @@
1
+ import { Database } from "bun:sqlite"
1
2
  import { afterEach, describe, expect, test } from "bun:test"
2
3
  import { Effect } from "effect"
3
4
  import * as fs from "node:fs"
4
5
  import * as os from "node:os"
5
6
  import * as path from "node:path"
6
7
  import { createDaemonManager } from "./daemon.js"
8
+ import { MOTEL_SERVICE_ID } from "./registry.js"
7
9
 
8
10
  const repoRoot = path.resolve(import.meta.dir, "..")
9
11
 
10
12
  const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
11
13
 
12
- const makeHarness = () => {
14
+ interface Harness {
15
+ readonly runtimeDir: string
16
+ readonly port: number
17
+ readonly databasePath: string
18
+ readonly manager: ReturnType<typeof createDaemonManager>
19
+ }
20
+
21
+ const makeHarness = (): Harness => {
13
22
  const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
23
+ const port = randomPort()
24
+ const databasePath = path.join(runtimeDir, "telemetry.sqlite")
14
25
  const manager = createDaemonManager({
15
26
  repoRoot,
16
27
  runtimeDir,
17
- databasePath: path.join(runtimeDir, "telemetry.sqlite"),
18
- port: randomPort(),
28
+ databasePath,
29
+ port,
19
30
  })
20
- return {
21
- runtimeDir,
22
- manager,
31
+ return { runtimeDir, port, databasePath, manager }
32
+ }
33
+
34
+ const withCwd = async <A>(cwd: string, f: () => Promise<A>): Promise<A> => {
35
+ const previous = process.cwd()
36
+ process.chdir(cwd)
37
+ try {
38
+ return await f()
39
+ } finally {
40
+ process.chdir(previous)
23
41
  }
24
42
  }
25
43
 
44
+ /**
45
+ * Start a motel-shaped HTTP server on a test port that answers
46
+ * /api/health with an arbitrary delay. Used to simulate a real daemon
47
+ * that's alive + holding the port but currently slow — the exact
48
+ * scenario that makes `bun dev` fail with EADDRINUSE when the
49
+ * supervisor's health probe times out and it tries to spawn a
50
+ * duplicate. Returns a stop() that releases the port.
51
+ */
52
+ const startFakeDaemon = (opts: {
53
+ readonly port: number
54
+ readonly databasePath: string
55
+ readonly delayMs: number
56
+ }) => {
57
+ const startedAt = new Date().toISOString()
58
+ const server = Bun.serve({
59
+ port: opts.port,
60
+ hostname: "127.0.0.1",
61
+ async fetch(req) {
62
+ const url = new URL(req.url)
63
+ if (url.pathname !== "/api/health") {
64
+ return new Response("not found", { status: 404 })
65
+ }
66
+ if (opts.delayMs > 0) {
67
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs))
68
+ }
69
+ return Response.json({
70
+ ok: true,
71
+ service: MOTEL_SERVICE_ID,
72
+ databasePath: opts.databasePath,
73
+ pid: process.pid,
74
+ url: `http://127.0.0.1:${opts.port}`,
75
+ workdir: process.cwd(),
76
+ startedAt,
77
+ version: "0.0.0-test",
78
+ })
79
+ },
80
+ })
81
+ return { stop: () => server.stop(true) }
82
+ }
83
+
26
84
  const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
27
85
 
28
86
  afterEach(async () => {
@@ -33,6 +91,96 @@ afterEach(async () => {
33
91
  })
34
92
 
35
93
  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.
101
+ const harness = makeHarness()
102
+ activeHarnesses.push(harness)
103
+
104
+ // Scope the motel registry to the harness's runtime dir so we
105
+ // neither read nor pollute the user's real ~/.local/state/motel.
106
+ const registryRoot = path.join(harness.runtimeDir, "state")
107
+ const originalXdg = process.env.XDG_STATE_HOME
108
+ process.env.XDG_STATE_HOME = registryRoot
109
+ const registryInstancesDir = path.join(registryRoot, "motel", "instances")
110
+ fs.mkdirSync(registryInstancesDir, { recursive: true })
111
+
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.
116
+ const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
117
+ fs.writeFileSync(entryPath, JSON.stringify({
118
+ pid: process.pid,
119
+ url: `http://127.0.0.1:${harness.port}`,
120
+ workdir: process.cwd(),
121
+ startedAt: new Date().toISOString(),
122
+ version: "0.0.0-test",
123
+ databasePath: harness.databasePath,
124
+ }), "utf8")
125
+
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
+ const fake = startFakeDaemon({
130
+ port: harness.port,
131
+ databasePath: harness.databasePath,
132
+ delayMs: 5_000,
133
+ })
134
+
135
+ try {
136
+ const start = performance.now()
137
+ const status = await Effect.runPromise(harness.manager.ensure)
138
+ const elapsed = performance.now() - start
139
+ expect(status.running).toBe(true)
140
+ expect(status.managed).toBe(true)
141
+ 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)
146
+ } finally {
147
+ fake.stop()
148
+ fs.rmSync(entryPath, { force: true })
149
+ if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
150
+ else process.env.XDG_STATE_HOME = originalXdg
151
+ }
152
+ })
153
+
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.
166
+ const harness = makeHarness()
167
+ activeHarnesses.push(harness)
168
+ const fake = startFakeDaemon({
169
+ port: harness.port,
170
+ databasePath: harness.databasePath,
171
+ delayMs: 1_500,
172
+ })
173
+ 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)
179
+ } finally {
180
+ fake.stop()
181
+ }
182
+ })
183
+
36
184
  test("starts once, reuses the same daemon, and stops cleanly", async () => {
37
185
  const harness = makeHarness()
38
186
  activeHarnesses.push(harness)
@@ -56,4 +204,63 @@ describe("daemon manager", () => {
56
204
  const finalStatus = await Effect.runPromise(harness.manager.getStatus)
57
205
  expect(finalStatus.running).toBe(false)
58
206
  })
207
+
208
+ test("becomes healthy even if trace summary rebuild hits a write lock", async () => {
209
+ const harness = makeHarness()
210
+ activeHarnesses.push(harness)
211
+
212
+ const firstStart = await Effect.runPromise(harness.manager.ensure)
213
+ expect(firstStart.running).toBe(true)
214
+ await Effect.runPromise(harness.manager.stop)
215
+
216
+ const locker = new Database(harness.databasePath)
217
+ locker.exec("BEGIN IMMEDIATE")
218
+ try {
219
+ const startedAt = performance.now()
220
+ const restarted = await Effect.runPromise(harness.manager.ensure)
221
+ const elapsed = performance.now() - startedAt
222
+ expect(restarted.running).toBe(true)
223
+ expect(restarted.managed).toBe(true)
224
+ expect(elapsed).toBeLessThan(10_000)
225
+ } finally {
226
+ locker.exec("ROLLBACK")
227
+ locker.close()
228
+ }
229
+ }, 20_000)
230
+
231
+ test("starts for the caller cwd even when motel is installed elsewhere", async () => {
232
+ const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
233
+ const databasePath = path.join(projectDir, ".motel-data", "telemetry.sqlite")
234
+ let manager: ReturnType<typeof createDaemonManager> | null = null
235
+
236
+ try {
237
+ await withCwd(projectDir, async () => {
238
+ manager = createDaemonManager({
239
+ repoRoot,
240
+ port: randomPort(),
241
+ })
242
+
243
+ const started = await Effect.runPromise(manager.ensure)
244
+ expect(started.running).toBe(true)
245
+ expect(started.managed).toBe(true)
246
+ expect(started.workdir).toBe(projectDir)
247
+ expect(started.sameWorkdir).toBe(true)
248
+ expect(started.databasePath).toBe(databasePath)
249
+ expect(started.logPath).toBe(path.join(projectDir, ".motel-data", "daemon.log"))
250
+
251
+ const reused = await Effect.runPromise(manager.ensure)
252
+ expect(reused.pid).toBe(started.pid)
253
+
254
+ const stopped = await Effect.runPromise(manager.stop)
255
+ expect(stopped.running).toBe(false)
256
+ })
257
+ } finally {
258
+ await withCwd(projectDir, async () => {
259
+ if (manager) {
260
+ await Effect.runPromise(manager.stop).catch(() => undefined)
261
+ }
262
+ })
263
+ fs.rmSync(projectDir, { recursive: true, force: true })
264
+ }
265
+ })
59
266
  })