@kitlangton/motel 0.2.1 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -31,6 +31,18 @@
31
31
  - Effect LSP diagnostics over the whole project: `bunx effect-language-service diagnostics --project tsconfig.json --format text`
32
32
  - Effect LSP interactive setup wizard: `bunx effect-language-service setup`
33
33
 
34
+ ## Release Strategy
35
+ - npm package: `@kitlangton/motel`
36
+ - Current published npm `latest`: `0.2.4` (`npm view @kitlangton/motel dist-tags --json`)
37
+ - Tags are versioned as `vX.Y.Z` (`git tag --sort=-version:refname` shows `v0.2.4`, `v0.2.3`, ...)
38
+ - Publishing is handled by GitHub Actions in `.github/workflows/publish.yml`, not by local manual `npm publish`
39
+ - The publish workflow triggers on `git push` of tags matching `v*` or via manual `workflow_dispatch`
40
+ - The workflow runs `bun install --frozen-lockfile`, `bun run typecheck`, `bun run test`, then `npm publish --provenance`
41
+ - `npm publish` runs `prepublishOnly`, which builds the web UI via `bun run web:build`
42
+ - Before tagging a release, make sure the committed `package.json` version matches the intended git tag exactly
43
+ - Preferred release flow: update `package.json` version, commit the release changes, create tag `v<package.json version>`, push the commit and tag, then verify the GitHub Actions publish and npm dist-tags
44
+ - Do not create or push release tags from a dirty worktree with unrelated uncommitted changes; ask before including unrelated edits in a release
45
+
34
46
  ## Effect LSP
35
47
  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.
36
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.2.1",
3
+ "version": "0.2.5",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,9 +65,13 @@
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
70
  "story:chat": "bun run src/storybook/aiChatStory.tsx",
71
+ "bench:motel-cold-start": "bun run scripts/bench-motel-cold-start.ts",
72
+ "bench:ingest-logs": "bun run scripts/bench-ingest-logs.ts",
73
+ "bench:ingest-traces": "bun run scripts/bench-ingest-traces.ts",
74
+ "bench:search-spans": "bun run scripts/bench-search-spans.ts",
71
75
  "typecheck": "tsc --noEmit",
72
76
  "prepublishOnly": "bun run web:build"
73
77
  },
package/src/App.tsx CHANGED
@@ -7,6 +7,7 @@ import { Divider, FooterHints, HelpModal, PlainLine, SplitDivider, TextLine } fr
7
7
  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
+ import { startupBenchMark } from "./startupBench.js"
10
11
  import {
11
12
  type AttrFacetState,
12
13
  attrPickerIndexAtom,
@@ -28,10 +29,12 @@ import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
28
29
  import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
29
30
  import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
30
31
  import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
31
- import { getVisibleSpans } from "./ui/Waterfall.tsx"
32
+ import { getVisibleSpans } from "./ui/waterfallModel.ts"
32
33
 
33
34
  const NOTICE_TIMEOUT_MS = 2500
34
35
 
36
+ startupBenchMark("app_module_loaded")
37
+
35
38
  const buildHeaderModel = ({
36
39
  headerFooterWidth,
37
40
  selectedTraceService,
@@ -177,6 +180,7 @@ const AppOverlays = ({
177
180
  )
178
181
 
179
182
  export const App = () => {
183
+ startupBenchMark("app_render_started")
180
184
  const { width, height } = useTerminalDimensions()
181
185
  const [notice, setNotice] = useAtom(noticeAtom)
182
186
  const [selectedTheme] = useAtom(selectedThemeAtom)
@@ -261,6 +265,10 @@ export const App = () => {
261
265
  persistSelectedTheme(selectedTheme)
262
266
  }, [selectedTheme])
263
267
 
268
+ useEffect(() => {
269
+ startupBenchMark("app_effects_committed")
270
+ }, [])
271
+
264
272
  const { spanNavActive } = useKeyboardNav({
265
273
  selectedTrace,
266
274
  filteredTraces,
@@ -337,6 +345,7 @@ export const App = () => {
337
345
  // above an empty column and leaves a visible stale sliver when
338
346
  // toggling tab back and forth with the trace view.
339
347
  const showSplit = isWideLayout && detailView !== "service-logs"
348
+ startupBenchMark("app_render_ready")
340
349
 
341
350
  return (
342
351
  <box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
@@ -0,0 +1,291 @@
1
+ import { Effect } from "effect"
2
+ import { RGBA, TextAttributes } from "@opentui/core"
3
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
+ import { useEffect, useMemo, useState } from "react"
5
+ import { App } from "./App.js"
6
+ import { createDaemonManager, ensureManagedDaemon, getManagedDaemonStatus, type DaemonStatus } from "./daemon.js"
7
+ import { MOTEL_SERVICE_ID } from "./registry.js"
8
+ import { Divider, PlainLine, TextLine } from "./ui/primitives.tsx"
9
+ import { colors } from "./ui/theme.ts"
10
+
11
+ type ConflictStatus = DaemonStatus & {
12
+ readonly service: typeof MOTEL_SERVICE_ID
13
+ readonly pid: number
14
+ readonly workdir: string
15
+ readonly reason: string
16
+ readonly sameWorkdir: false
17
+ }
18
+
19
+ type ConflictScreenState = {
20
+ kind: "conflict"
21
+ message: string
22
+ status: ConflictStatus
23
+ busy: boolean
24
+ notice: string | null
25
+ }
26
+
27
+ type ErrorScreenState = {
28
+ kind: "error"
29
+ message: string
30
+ busy: boolean
31
+ notice: string | null
32
+ }
33
+
34
+ type StartupState =
35
+ | { kind: "loading"; message: string }
36
+ | { kind: "ready" }
37
+ | ConflictScreenState
38
+ | ErrorScreenState
39
+
40
+ type RecoveryAction = {
41
+ readonly key: string
42
+ readonly label: string
43
+ readonly run: () => Promise<void>
44
+ readonly disabled?: boolean
45
+ }
46
+
47
+ const readStatus = () => Effect.runPromise(getManagedDaemonStatus)
48
+ const startDaemon = () => Effect.runPromise(ensureManagedDaemon)
49
+
50
+ const parsePort = (url: string) => {
51
+ try {
52
+ const port = Number(new URL(url).port)
53
+ return Number.isFinite(port) && port > 0 ? port : undefined
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ const isRecoverableConflict = (status: DaemonStatus | null): status is ConflictStatus =>
60
+ status !== null &&
61
+ status.service === MOTEL_SERVICE_ID &&
62
+ status.pid !== null &&
63
+ status.workdir !== null &&
64
+ status.reason !== null &&
65
+ !status.sameWorkdir
66
+
67
+ const stopConflictingDaemon = async (status: ConflictStatus) => {
68
+ const port = parsePort(status.url)
69
+ const manager = createDaemonManager({
70
+ workdir: status.workdir ?? undefined,
71
+ databasePath: status.databasePath,
72
+ port,
73
+ })
74
+ await Effect.runPromise(manager.stop)
75
+ }
76
+
77
+ const LoadingScreen = ({ width, height, message }: { width: number; height: number; message: string }) => {
78
+ const panelWidth = Math.min(76, Math.max(50, width - 8))
79
+ const left = Math.max(0, Math.floor((width - panelWidth) / 2))
80
+ const top = Math.max(0, Math.floor((height - 5) / 2))
81
+
82
+ return (
83
+ <box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
84
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
85
+ <TextLine>
86
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
87
+ <span fg={colors.separator}>{" · "}</span>
88
+ <span fg={colors.muted}>starting up...</span>
89
+ </TextLine>
90
+ <Divider width={panelWidth} />
91
+ <box paddingTop={1}>
92
+ <PlainLine text={message} fg={colors.count} />
93
+ </box>
94
+ </box>
95
+ </box>
96
+ )
97
+ }
98
+
99
+ const RecoveryScreen = ({
100
+ title,
101
+ message,
102
+ width,
103
+ height,
104
+ detailLines,
105
+ actions,
106
+ selectedIndex,
107
+ notice,
108
+ busy,
109
+ }: {
110
+ readonly title: string
111
+ readonly message: string
112
+ readonly width: number
113
+ readonly height: number
114
+ readonly detailLines: readonly string[]
115
+ readonly actions: readonly RecoveryAction[]
116
+ readonly selectedIndex: number
117
+ readonly notice: string | null
118
+ readonly busy: boolean
119
+ }) => {
120
+ const panelWidth = Math.min(96, Math.max(64, width - 8))
121
+ const left = Math.max(0, Math.floor((width - panelWidth) / 2))
122
+ const bodyHeight = 9 + detailLines.length + actions.length + (notice ? 2 : 0)
123
+ const top = Math.max(0, Math.floor((height - bodyHeight) / 2))
124
+
125
+ return (
126
+ <box width={width} height={height} backgroundColor={RGBA.fromHex(colors.screenBg)}>
127
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column">
128
+ <TextLine>
129
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>MOTEL</span>
130
+ <span fg={colors.separator}>{" · "}</span>
131
+ <span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
132
+ </TextLine>
133
+ <Divider width={panelWidth} />
134
+ <box paddingTop={1} paddingBottom={1} flexDirection="column">
135
+ <PlainLine text={message} fg={colors.text} />
136
+ </box>
137
+ {detailLines.map((line, index) => (
138
+ <PlainLine key={`${index}:${line}`} text={line} fg={colors.muted} />
139
+ ))}
140
+ <box paddingTop={1} flexDirection="column">
141
+ {actions.map((action, index) => {
142
+ const selected = index === selectedIndex
143
+ const prefix = selected ? ">" : " "
144
+ const text = `${prefix} [${action.key}] ${action.label}${action.disabled ? " (unavailable)" : ""}`
145
+ return (
146
+ <TextLine key={action.key} bg={selected ? colors.selectedBg : undefined}>
147
+ <span fg={selected ? colors.selectedText : colors.text}>{text}</span>
148
+ </TextLine>
149
+ )
150
+ })}
151
+ </box>
152
+ <box paddingTop={1} flexDirection="column">
153
+ {notice ? <PlainLine text={notice} fg={busy ? colors.warning : colors.count} /> : null}
154
+ <PlainLine text={busy ? "Working..." : "j/k or ↑↓ select · enter run · r retry · k kill conflicting daemon · q quit"} fg={colors.count} />
155
+ </box>
156
+ </box>
157
+ </box>
158
+ )
159
+ }
160
+
161
+ export const StartupGate = () => {
162
+ const renderer = useRenderer()
163
+ const { width = 100, height = 24 } = useTerminalDimensions()
164
+ const [startupState, setStartupState] = useState<StartupState>({ kind: "loading", message: "Checking managed daemon..." })
165
+ const [selectedIndex, setSelectedIndex] = useState(0)
166
+
167
+ const attemptStart = async () => {
168
+ setStartupState({ kind: "loading", message: "Checking managed daemon..." })
169
+ try {
170
+ await startDaemon()
171
+ setStartupState({ kind: "ready" })
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error)
174
+ const status = await readStatus().catch(() => null)
175
+ if (isRecoverableConflict(status)) {
176
+ setSelectedIndex(0)
177
+ setStartupState({ kind: "conflict", message, status, busy: false, notice: null })
178
+ return
179
+ }
180
+ setSelectedIndex(0)
181
+ setStartupState({ kind: "error", message, busy: false, notice: null })
182
+ }
183
+ }
184
+
185
+ useEffect(() => {
186
+ void attemptStart()
187
+ }, [])
188
+
189
+ const actions = useMemo<readonly RecoveryAction[]>(() => {
190
+ if (startupState.kind === "conflict") {
191
+ return [
192
+ { key: "r", label: "Retry startup", run: attemptStart },
193
+ {
194
+ key: "k",
195
+ label: `Stop conflicting daemon (${startupState.status.pid})`,
196
+ run: async () => {
197
+ setStartupState((current) => current.kind === "conflict"
198
+ ? { ...current, busy: true, notice: `Stopping daemon ${current.status.pid}...` }
199
+ : current)
200
+ try {
201
+ await stopConflictingDaemon(startupState.status)
202
+ await attemptStart()
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error)
205
+ setStartupState((current) => current.kind === "conflict"
206
+ ? { ...current, busy: false, notice: message }
207
+ : current)
208
+ }
209
+ },
210
+ },
211
+ { key: "q", label: "Quit", run: async () => { renderer.destroy() } },
212
+ ]
213
+ }
214
+ if (startupState.kind === "error") {
215
+ return [
216
+ { key: "r", label: "Retry startup", run: attemptStart },
217
+ { key: "q", label: "Quit", run: async () => { renderer.destroy() } },
218
+ ]
219
+ }
220
+ return []
221
+ }, [renderer, startupState])
222
+
223
+ useKeyboard((key) => {
224
+ if (startupState.kind === "ready" || startupState.kind === "loading" || key.repeated) return
225
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
226
+ renderer.destroy()
227
+ return
228
+ }
229
+ if (key.name === "up" || key.name === "k") {
230
+ setSelectedIndex((current) => (current + actions.length - 1) % actions.length)
231
+ return
232
+ }
233
+ if (key.name === "down" || key.name === "j") {
234
+ setSelectedIndex((current) => (current + 1) % actions.length)
235
+ return
236
+ }
237
+ if (key.name === "r") {
238
+ void actions.find((action) => action.key === "r")?.run()
239
+ return
240
+ }
241
+ if (key.name === "k") {
242
+ void actions.find((action) => action.key === "k")?.run()
243
+ return
244
+ }
245
+ if (key.name === "return" || key.name === "enter") {
246
+ void actions[selectedIndex]?.run()
247
+ }
248
+ })
249
+
250
+ if (startupState.kind === "ready") return <App />
251
+ if (startupState.kind === "loading") return <LoadingScreen width={width} height={height} message={startupState.message} />
252
+ if (startupState.kind === "conflict") {
253
+ const status = startupState.status
254
+ const detailLines = [
255
+ `Port: ${status.url}`,
256
+ `Conflicting workdir: ${status.workdir}`,
257
+ `Conflicting pid: ${status.pid}`,
258
+ `Database: ${status.databasePath}`,
259
+ status.workdir.startsWith("/tmp") || status.workdir.startsWith("/private/tmp")
260
+ ? "This looks like a temp/test daemon."
261
+ : "This looks like a real motel daemon started from another project.",
262
+ ]
263
+ return (
264
+ <RecoveryScreen
265
+ title="Daemon Conflict"
266
+ message={startupState.message}
267
+ width={width}
268
+ height={height}
269
+ detailLines={detailLines}
270
+ actions={actions}
271
+ selectedIndex={selectedIndex}
272
+ notice={startupState.notice}
273
+ busy={startupState.busy}
274
+ />
275
+ )
276
+ }
277
+
278
+ return (
279
+ <RecoveryScreen
280
+ title="Startup Error"
281
+ message={startupState.message}
282
+ width={width}
283
+ height={height}
284
+ detailLines={["Retry startup or quit the TUI."]}
285
+ actions={actions}
286
+ selectedIndex={selectedIndex}
287
+ notice={startupState.notice}
288
+ busy={startupState.busy}
289
+ />
290
+ )
291
+ }
@@ -1,3 +1,4 @@
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"
@@ -30,6 +31,16 @@ const makeHarness = (): Harness => {
30
31
  return { runtimeDir, port, databasePath, manager }
31
32
  }
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)
41
+ }
42
+ }
43
+
33
44
  /**
34
45
  * Start a motel-shaped HTTP server on a test port that answers
35
46
  * /api/health with an arbitrary delay. Used to simulate a real daemon
@@ -193,4 +204,63 @@ describe("daemon manager", () => {
193
204
  const finalStatus = await Effect.runPromise(harness.manager.getStatus)
194
205
  expect(finalStatus.running).toBe(false)
195
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
+ })
196
266
  })