@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- 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
|
+
})
|