@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,38 @@
1
+ # Effect Notes
2
+
3
+ Apply these only when the target repo already uses Effect or `@effect/*`.
4
+
5
+ ## Runtime
6
+
7
+ - Inspect the existing runtime and observability wiring before adding anything new.
8
+ - Prefer the repo's existing Effect-native observability APIs if they already exist.
9
+ - If `effect/unstable/observability` is already the best fit, prefer it over adding new OTEL packages.
10
+ - Merge telemetry into the main runtime once, not per feature or per request path.
11
+
12
+ ## Instrumentation
13
+
14
+ - Prefer `Effect.fn("...")` for meaningful workflow spans.
15
+ - Add a few child spans around boundaries that are likely to fail or add latency.
16
+ - Emit `Effect.logInfo`, `Effect.logWarning`, and `Effect.logError` with structured fields.
17
+ - Put searchable values in annotations/attributes, not only in the free-form log body.
18
+ - Reuse stable debug keys such as `debug.session`, `debug.hypothesis`, `debug.step`, and `debug.label`.
19
+
20
+ ## Debug Blocks
21
+
22
+ Wrap temporary debug-only Effect instrumentation in removable markers.
23
+
24
+ ```ts
25
+ // #region motel debug
26
+ const program = Effect.fn("feature/doThing")(function*() {
27
+ yield* Effect.logInfo("entered doThing", {
28
+ debug: {
29
+ session: "abc123",
30
+ hypothesis: "cache-miss",
31
+ step: "entry",
32
+ },
33
+ })
34
+ })
35
+ // #endregion motel debug
36
+ ```
37
+
38
+ Keep those blocks until the fix is verified, then remove them with the cleanup script.
package/src/App.tsx ADDED
@@ -0,0 +1,414 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { useAtom } from "@effect/atom-react"
3
+ import { useTerminalDimensions } from "@opentui/react"
4
+ import { useCallback, useEffect, useMemo, useRef } from "react"
5
+ import { formatTimestamp } from "./ui/format.ts"
6
+ import { Divider, FooterHints, HelpModal, PlainLine, SplitDivider, TextLine } from "./ui/primitives.tsx"
7
+ import { useAppLayout } from "./ui/app/useAppLayout.ts"
8
+ import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
9
+ import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
10
+ import { startupBenchMark } from "./startupBench.js"
11
+ import {
12
+ type AttrFacetState,
13
+ attrPickerIndexAtom,
14
+ attrPickerInputAtom,
15
+ attrPickerModeAtom,
16
+ attrFacetStateAtom,
17
+ noticeAtom,
18
+ selectedAttrIndexAtom,
19
+ selectedThemeAtom,
20
+ waterfallFilterModeAtom,
21
+ waterfallFilterTextAtom,
22
+ } from "./ui/atoms.ts"
23
+ import { chatDetailChunkIdAtom, chatDetailScrollOffsetAtom, selectedChatChunkIdAtom } from "./ui/aiState.ts"
24
+ import { persistSelectedTheme } from "./ui/persistence.ts"
25
+ import type { ThemeName } from "./ui/theme.ts"
26
+ import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
27
+ import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
28
+ import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
29
+ import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
30
+ import { getVisibleSpans } from "./ui/waterfallModel.ts"
31
+
32
+ const NOTICE_TIMEOUT_MS = 2500
33
+
34
+ startupBenchMark("app_module_loaded")
35
+
36
+ const buildHeaderModel = ({
37
+ headerFooterWidth,
38
+ selectedTraceService,
39
+ activeAttrKey,
40
+ activeAttrValue,
41
+ autoRefresh,
42
+ fetchedAt,
43
+ status,
44
+ }: {
45
+ readonly headerFooterWidth: number
46
+ readonly selectedTraceService: string | null
47
+ readonly activeAttrKey: string | null
48
+ readonly activeAttrValue: string | null
49
+ readonly autoRefresh: boolean
50
+ readonly fetchedAt: Date | null
51
+ readonly status: string
52
+ }) => {
53
+ const serviceLabel = selectedTraceService ?? "none"
54
+ const autoLabel = autoRefresh ? "● live" : "○ paused"
55
+ const attrFilterLabel = activeAttrKey && activeAttrValue
56
+ ? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
57
+ : ""
58
+ const right = fetchedAt
59
+ ? `${autoLabel} ${formatTimestamp(fetchedAt)}`
60
+ : status === "loading"
61
+ ? "loading traces..."
62
+ : ""
63
+ const leftLength = "MOTEL".length + SEPARATOR.length + serviceLabel.length + attrFilterLabel.length
64
+ const gap = Math.max(2, headerFooterWidth - leftLength - right.length)
65
+
66
+ return {
67
+ serviceLabel,
68
+ attrFilterLabel,
69
+ right,
70
+ gap,
71
+ } as const
72
+ }
73
+
74
+ const AppHeader = ({
75
+ serviceLabel,
76
+ attrFilterLabel,
77
+ gap,
78
+ right,
79
+ }: {
80
+ readonly serviceLabel: string
81
+ readonly attrFilterLabel: string
82
+ readonly gap: number
83
+ readonly right: string
84
+ }) => (
85
+ <box paddingLeft={1} paddingRight={1} flexDirection="column">
86
+ <TextLine>
87
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
88
+ <span fg={colors.separator}>{SEPARATOR}</span>
89
+ <span fg={colors.muted}>{serviceLabel}</span>
90
+ {attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
91
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
92
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>{right}</span>
93
+ </TextLine>
94
+ </box>
95
+ )
96
+
97
+ const AppFooter = ({
98
+ showSplit,
99
+ footerHeight,
100
+ contentWidth,
101
+ leftPaneWidth,
102
+ rightPaneWidth,
103
+ footerNotice,
104
+ spanNavActive,
105
+ detailView,
106
+ autoRefresh,
107
+ headerFooterWidth,
108
+ }: {
109
+ readonly showSplit: boolean
110
+ readonly footerHeight: number
111
+ readonly contentWidth: number
112
+ readonly leftPaneWidth: number
113
+ readonly rightPaneWidth: number
114
+ readonly footerNotice: string | null
115
+ readonly spanNavActive: boolean
116
+ readonly detailView: "waterfall" | "span-detail" | "service-logs"
117
+ readonly autoRefresh: boolean
118
+ readonly headerFooterWidth: number
119
+ }) => {
120
+ if (footerHeight <= 0) return null
121
+
122
+ return (
123
+ <>
124
+ {showSplit
125
+ ? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
126
+ : <Divider width={contentWidth} />}
127
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
128
+ {footerNotice ? (
129
+ <PlainLine text={footerNotice} fg={colors.count} />
130
+ ) : (
131
+ <FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
132
+ )}
133
+ </box>
134
+ </>
135
+ )
136
+ }
137
+
138
+ const AppOverlays = ({
139
+ width,
140
+ height,
141
+ showHelp,
142
+ autoRefresh,
143
+ selectedTheme,
144
+ setShowHelp,
145
+ pickerMode,
146
+ pickerInput,
147
+ pickerIndex,
148
+ activeAttrKey,
149
+ attrFacets,
150
+ }: {
151
+ readonly width: number
152
+ readonly height: number
153
+ readonly showHelp: boolean
154
+ readonly autoRefresh: boolean
155
+ readonly selectedTheme: ThemeName
156
+ readonly setShowHelp: (value: boolean) => void
157
+ readonly pickerMode: "off" | "keys" | "values"
158
+ readonly pickerInput: string
159
+ readonly pickerIndex: number
160
+ readonly activeAttrKey: string | null
161
+ readonly attrFacets: AttrFacetState
162
+ }) => (
163
+ <>
164
+ {showHelp ? <HelpModal width={width} height={height} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
165
+ {pickerMode !== "off" ? (
166
+ <AttrFilterModal
167
+ width={width}
168
+ height={height}
169
+ mode={pickerMode}
170
+ input={pickerInput}
171
+ selectedIndex={pickerIndex}
172
+ selectedKey={activeAttrKey}
173
+ state={attrFacets}
174
+ onClose={() => { /* handled via keyboard */ }}
175
+ />
176
+ ) : null}
177
+ </>
178
+ )
179
+
180
+ export const App = () => {
181
+ startupBenchMark("app_render_started")
182
+ const { width, height } = useTerminalDimensions()
183
+ const [notice, setNotice] = useAtom(noticeAtom)
184
+ const [selectedTheme] = useAtom(selectedThemeAtom)
185
+ applyTheme(selectedTheme)
186
+ const {
187
+ traceState,
188
+ traceDetailState,
189
+ logState,
190
+ serviceLogState,
191
+ selectedServiceLogIndex,
192
+ setSelectedServiceLogIndex,
193
+ selectedTraceIndex,
194
+ setSelectedTraceIndex,
195
+ selectedTraceService,
196
+ selectedSpanIndex,
197
+ setSelectedSpanIndex,
198
+ detailView,
199
+ showHelp,
200
+ setShowHelp,
201
+ collapsedSpanIds,
202
+ autoRefresh,
203
+ filterMode,
204
+ filterText,
205
+ activeAttrKey,
206
+ activeAttrValue,
207
+ traceSort,
208
+ selectedTraceSummary,
209
+ selectedTrace,
210
+ filteredTraces,
211
+ aiCallDetailState,
212
+ aiChatChunks,
213
+ } = useTraceScreenData()
214
+ const [pickerMode] = useAtom(attrPickerModeAtom)
215
+ const [pickerInput] = useAtom(attrPickerInputAtom)
216
+ const [pickerIndex] = useAtom(attrPickerIndexAtom)
217
+ const [attrFacets] = useAtom(attrFacetStateAtom)
218
+ const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
219
+ const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
220
+ const [selectedAttrIndex] = useAtom(selectedAttrIndexAtom)
221
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
222
+ const [chatDetailChunkId, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
223
+ const [chatDetailScrollOffset, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
224
+ useAttrFilterPicker(activeAttrKey)
225
+
226
+ const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
227
+ const {
228
+ contentWidth,
229
+ isWideLayout,
230
+ viewLevel,
231
+ footerNotice,
232
+ footerHeight,
233
+ leftPaneWidth,
234
+ rightPaneWidth,
235
+ leftContentWidth,
236
+ headerFooterWidth,
237
+ wideBodyLines,
238
+ narrowBodyLines,
239
+ traceViewportRows,
240
+ tracePageSize,
241
+ spanPageSize,
242
+ } = layout
243
+
244
+ const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
245
+
246
+ const flashNotice = (message: string) => {
247
+ if (noticeTimeoutRef.current !== null) {
248
+ clearTimeout(noticeTimeoutRef.current)
249
+ }
250
+ setNotice(message)
251
+ noticeTimeoutRef.current = globalThis.setTimeout(() => {
252
+ setNotice((current) => (current === message ? null : current))
253
+ }, NOTICE_TIMEOUT_MS)
254
+ }
255
+
256
+ useEffect(() => () => {
257
+ if (noticeTimeoutRef.current !== null) {
258
+ clearTimeout(noticeTimeoutRef.current)
259
+ }
260
+ }, [setNotice])
261
+
262
+ useEffect(() => {
263
+ persistSelectedTheme(selectedTheme)
264
+ }, [selectedTheme])
265
+
266
+ useEffect(() => {
267
+ startupBenchMark("app_effects_committed")
268
+ }, [])
269
+
270
+ const { spanNavActive } = useKeyboardNav({
271
+ selectedTrace,
272
+ filteredTraces,
273
+ aiChatChunks,
274
+ isWideLayout,
275
+ wideBodyLines,
276
+ narrowBodyLines,
277
+ tracePageSize,
278
+ spanPageSize,
279
+ flashNotice,
280
+ })
281
+
282
+ const headerModel = buildHeaderModel({
283
+ headerFooterWidth,
284
+ selectedTraceService,
285
+ activeAttrKey,
286
+ activeAttrValue,
287
+ autoRefresh,
288
+ fetchedAt: traceState.fetchedAt,
289
+ status: traceState.status,
290
+ })
291
+
292
+ const selectTraceById = useCallback((traceId: string) => {
293
+ const index = traceState.data.findIndex((trace) => trace.traceId === traceId)
294
+ if (index >= 0) setSelectedTraceIndex(index)
295
+ }, [setSelectedTraceIndex, traceState.data])
296
+
297
+ const visibleSpans = useMemo(
298
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
299
+ [selectedTrace, collapsedSpanIds],
300
+ )
301
+
302
+ const openChatChunkDetail = useCallback((chunkId: string) => {
303
+ setSelectedChatChunkId(chunkId)
304
+ setChatDetailChunkId(chunkId)
305
+ setChatDetailScrollOffset(0)
306
+ }, [setSelectedChatChunkId, setChatDetailChunkId, setChatDetailScrollOffset])
307
+
308
+ const closeChatChunkDetail = useCallback(() => {
309
+ setChatDetailChunkId(null)
310
+ setChatDetailScrollOffset(0)
311
+ }, [setChatDetailChunkId, setChatDetailScrollOffset])
312
+
313
+ const selectSpan = useCallback((index: number) => {
314
+ if (visibleSpans.length === 0) return
315
+ setSelectedSpanIndex(Math.max(0, Math.min(index, visibleSpans.length - 1)))
316
+ }, [setSelectedSpanIndex, visibleSpans])
317
+
318
+ const traceListProps = useMemo(() => ({
319
+ traces: filteredTraces,
320
+ selectedTraceId: selectedTraceSummary?.traceId ?? null,
321
+ status: traceState.status,
322
+ error: traceState.error,
323
+ contentWidth: leftContentWidth,
324
+ services: traceState.services,
325
+ selectedService: selectedTraceService,
326
+ focused: !spanNavActive,
327
+ filterText: filterText || undefined,
328
+ sortMode: traceSort,
329
+ totalCount: filterText ? traceState.data.length : undefined,
330
+ onSelectTrace: selectTraceById,
331
+ } as const), [filteredTraces, selectedTraceSummary?.traceId, traceState.status, traceState.error, leftContentWidth, traceState.services, selectedTraceService, spanNavActive, filterText, traceSort, traceState.data.length, selectTraceById])
332
+
333
+ const selectedSpan = selectedSpanIndex !== null ? visibleSpans[selectedSpanIndex] ?? null : null
334
+ const selectedSpanLogs = useMemo(
335
+ () => selectedSpan ? logState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
336
+ [selectedSpan, logState.data],
337
+ )
338
+
339
+ // Top/bottom frame dividers only render junction glyphs (`┬` / `┴`)
340
+ // when there's actually a vertical SeparatorColumn in the workspace
341
+ // below/above them to meet. Service-logs view is wide but single-pane,
342
+ // so its frame dividers must be plain — otherwise the junction floats
343
+ // above an empty column and leaves a visible stale sliver when
344
+ // toggling tab back and forth with the trace view.
345
+ const showSplit = isWideLayout && detailView !== "service-logs"
346
+ startupBenchMark("app_render_ready")
347
+
348
+ return (
349
+ <box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
350
+ <AppHeader {...headerModel} />
351
+ {showSplit
352
+ ? <SplitDivider leftWidth={leftPaneWidth} junction={"┬"} rightWidth={rightPaneWidth} />
353
+ : <Divider width={contentWidth} />}
354
+ <TraceWorkspace
355
+ layout={layout}
356
+ detailView={detailView}
357
+ filterMode={filterMode}
358
+ filterText={filterText}
359
+ waterfallFilterMode={waterfallFilterMode}
360
+ waterfallFilterText={waterfallFilterText}
361
+ traceListProps={traceListProps}
362
+ selectedTraceService={selectedTraceService}
363
+ serviceLogState={serviceLogState}
364
+ selectedServiceLogIndex={selectedServiceLogIndex}
365
+ setSelectedServiceLogIndex={setSelectedServiceLogIndex}
366
+ traceDetailState={traceDetailState}
367
+ selectedTrace={selectedTrace}
368
+ selectedTraceSummary={selectedTraceSummary}
369
+ logState={logState}
370
+ selectedSpanIndex={selectedSpanIndex}
371
+ collapsedSpanIds={collapsedSpanIds}
372
+ viewLevel={viewLevel}
373
+ selectedSpan={selectedSpan}
374
+ selectedSpanLogs={selectedSpanLogs}
375
+ selectedAttrIndex={selectedAttrIndex}
376
+ aiCallDetailState={aiCallDetailState}
377
+ aiChatChunks={aiChatChunks}
378
+ selectedChatChunkId={selectedChatChunkId}
379
+ onSelectChatChunk={setSelectedChatChunkId}
380
+ chatDetailChunkId={chatDetailChunkId}
381
+ onOpenChatChunkDetail={openChatChunkDetail}
382
+ onCloseChatChunkDetail={closeChatChunkDetail}
383
+ chatDetailScrollOffset={chatDetailScrollOffset}
384
+ onSetChatDetailScrollOffset={(updater) => setChatDetailScrollOffset(updater)}
385
+ selectSpan={selectSpan}
386
+ />
387
+ <AppFooter
388
+ showSplit={showSplit}
389
+ footerHeight={footerHeight}
390
+ contentWidth={contentWidth}
391
+ leftPaneWidth={leftPaneWidth}
392
+ rightPaneWidth={rightPaneWidth}
393
+ footerNotice={footerNotice}
394
+ spanNavActive={spanNavActive}
395
+ detailView={detailView}
396
+ autoRefresh={autoRefresh}
397
+ headerFooterWidth={headerFooterWidth}
398
+ />
399
+ <AppOverlays
400
+ width={width ?? 100}
401
+ height={height ?? 24}
402
+ showHelp={showHelp}
403
+ autoRefresh={autoRefresh}
404
+ selectedTheme={selectedTheme}
405
+ setShowHelp={setShowHelp}
406
+ pickerMode={pickerMode}
407
+ pickerInput={pickerInput}
408
+ pickerIndex={pickerIndex}
409
+ activeAttrKey={activeAttrKey}
410
+ attrFacets={attrFacets}
411
+ />
412
+ </box>
413
+ )
414
+ }
@@ -0,0 +1,80 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { startDaemon, stopConflictingDaemon } from "./startupLifecycle.js"
4
+ import type { DaemonManager, DaemonStatus } from "./daemon.js"
5
+ import type { LaunchAgentManager, LaunchAgentStatus, MotelLifecycle } from "./launchAgent.js"
6
+
7
+ test("startup lifecycle starts through the supervisor-aware lifecycle", async () => {
8
+ const events: string[] = []
9
+ const lifecycle = {
10
+ start: Effect.sync(() => { events.push("lifecycle:start"); return {} }),
11
+ } as unknown as MotelLifecycle
12
+ await startDaemon(lifecycle)
13
+ expect(events).toEqual(["lifecycle:start"])
14
+ })
15
+
16
+ const conflict = {
17
+ running: true,
18
+ managed: false,
19
+ service: "motel-local-server" as const,
20
+ pid: 321,
21
+ url: "http://127.0.0.1:27686",
22
+ databasePath: "/tmp/conflict.sqlite",
23
+ workdir: "/tmp/conflict",
24
+ startedAt: null,
25
+ version: null,
26
+ sameWorkdir: false,
27
+ reason: "conflict",
28
+ logPath: "/tmp/conflict.log",
29
+ lockPath: "/tmp/conflict.lock",
30
+ registryPid: 321,
31
+ }
32
+
33
+ const serviceStatus = (pid: number, options: { readonly configuration?: LaunchAgentStatus["configuration"]; readonly manager?: LaunchAgentStatus["manager"] } = {}): LaunchAgentStatus => ({
34
+ installed: options.configuration === undefined || options.configuration === "equivalent",
35
+ configuration: options.configuration ?? "equivalent",
36
+ configurationDetails: [],
37
+ manager: options.manager ?? "loaded",
38
+ running: true,
39
+ health: { ...conflict, pid } as DaemonStatus,
40
+ registryIdentity: "verified",
41
+ version: { cli: "0.2.6", server: "0.2.6", drift: false },
42
+ })
43
+
44
+ test("startup lifecycle boots out only the matching loaded LaunchAgent child", async () => {
45
+ const events: string[] = []
46
+ const service = {
47
+ available: true,
48
+ status: Effect.succeed(serviceStatus(conflict.pid)),
49
+ stop: Effect.sync(() => { events.push("service:stop"); return serviceStatus(conflict.pid) }),
50
+ } as unknown as LaunchAgentManager
51
+ await stopConflictingDaemon(conflict, {
52
+ service,
53
+ createManager: () => { throw new Error("detached manager should not be used") },
54
+ })
55
+ expect(events).toEqual(["service:stop"])
56
+ })
57
+
58
+ test("startup lifecycle refuses detached recovery while another LaunchAgent child is loaded", async () => {
59
+ const events: string[] = []
60
+ const service = { available: true, status: Effect.succeed(serviceStatus(999)) } as unknown as LaunchAgentManager
61
+ const manager = { stop: Effect.sync(() => { events.push("daemon:stop"); return {} }) } as unknown as DaemonManager
62
+ await expect(stopConflictingDaemon(conflict, { service, createManager: () => manager })).rejects.toThrow("different process")
63
+ expect(events).toEqual([])
64
+ })
65
+
66
+ test("startup lifecycle refuses detached recovery while a LaunchAgent definition remains present", async () => {
67
+ const events: string[] = []
68
+ const service = { available: true, status: Effect.succeed(serviceStatus(999, { manager: "not-loaded" })) } as unknown as LaunchAgentManager
69
+ const manager = { stop: Effect.sync(() => { events.push("daemon:stop"); return {} }) } as unknown as DaemonManager
70
+ await expect(stopConflictingDaemon(conflict, { service, createManager: () => manager })).rejects.toThrow("definition is present")
71
+ expect(events).toEqual([])
72
+ })
73
+
74
+ test("startup lifecycle fails closed when LaunchAgent ownership cannot be inspected", async () => {
75
+ const events: string[] = []
76
+ const service = { available: true, status: Effect.fail(new Error("plist unavailable")) } as unknown as LaunchAgentManager
77
+ const manager = { stop: Effect.sync(() => { events.push("daemon:stop"); return {} }) } as unknown as DaemonManager
78
+ await expect(stopConflictingDaemon(conflict, { service, createManager: () => manager })).rejects.toThrow("Refusing detached recovery")
79
+ expect(events).toEqual([])
80
+ })