@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 +12 -0
- package/package.json +7 -3
- package/src/App.tsx +10 -1
- package/src/StartupGate.tsx +291 -0
- package/src/daemon.test.ts +70 -0
- package/src/daemon.ts +67 -35
- package/src/index.tsx +9 -2
- package/src/localServer.ts +29 -23
- package/src/motel.ts +0 -2
- package/src/services/AsyncIngest.ts +22 -4
- package/src/services/LogQueryService.ts +2 -2
- package/src/services/TelemetryStore.ts +311 -162
- package/src/services/TraceQueryService.ts +6 -6
- package/src/startupBench.ts +19 -0
- package/src/storybook/aiChatStory.tsx +1 -0
- package/src/ui/AiChatView.tsx +25 -9
- package/src/ui/TraceDetailsPane.tsx +9 -27
- package/src/ui/Waterfall.tsx +5 -10
- package/src/ui/aiChatModel.test.ts +44 -0
- package/src/ui/aiChatModel.ts +38 -1
- package/src/ui/app/TraceWorkspace.tsx +4 -11
- package/src/ui/app/useTraceScreenData.ts +15 -7
- package/src/ui/atoms.ts +1 -1
- package/src/ui/persistence.ts +3 -3
- package/src/ui/theme.ts +7 -5
- package/src/ui/useKeyboardNav.ts +28 -2
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.
|
|
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": "
|
|
69
|
-
"web: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/
|
|
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
|
+
}
|
package/src/daemon.test.ts
CHANGED
|
@@ -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
|
})
|