@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,231 @@
1
+ /**
2
+ * End-to-end reproducer for "navigation broken after changing sort".
3
+ *
4
+ * Strategy:
5
+ * 1. Seed 5 traces with distinct durations so `recent` and `slowest` give
6
+ * obviously different orderings.
7
+ * 2. Launch the TUI, capture the default (recent) order and the selected row.
8
+ * 3. Press `s` to switch to `slowest`, capture the new order.
9
+ * 4. Drive `j` / `k` and make sure the highlighted row steps through the
10
+ * *sorted* list, not the underlying raw order.
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
15
+ import { tmpdir } from "node:os"
16
+ import { join } from "node:path"
17
+
18
+ const TUISTORY_BIN = "tuistory"
19
+ const SESSION = `motel-sort-${Date.now()}`
20
+
21
+ const hasTuistory = async () => {
22
+ try {
23
+ const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
24
+ return (await proc.exited) === 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ const tui = async (args: readonly string[]) => {
31
+ const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
32
+ const [stdout, stderr] = await Promise.all([
33
+ new Response(proc.stdout).text(),
34
+ new Response(proc.stderr).text(),
35
+ ])
36
+ return { code: await proc.exited, stdout, stderr }
37
+ }
38
+
39
+ const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
40
+
41
+ const press = async (...keys: string[]) => {
42
+ await tui(["press", "--session", SESSION, ...keys])
43
+ await Bun.sleep(120)
44
+ }
45
+
46
+ /**
47
+ * Extract the ordered list of operation names that appear as trace rows in
48
+ * the left pane, and the currently-selected operation inferred from the
49
+ * TRACE DETAILS pane on the right (whose second row shows the selected
50
+ * trace's root operation name). tuistory's plain-text snapshots don't
51
+ * preserve background color, so the visual "selected row" marker in
52
+ * TraceList isn't visible — we use the right pane as the source of truth.
53
+ */
54
+ const listRows = (snap: string): { readonly rows: readonly string[]; readonly selected: string | null } => {
55
+ const rows: string[] = []
56
+ let selected: string | null = null
57
+ let inDetailsPane = false
58
+ let detailsLinesConsumed = 0
59
+ for (const raw of snap.split("\n")) {
60
+ const leftHalf = raw.split("\u2502")[0] ?? raw
61
+ const rightHalf = raw.includes("\u2502") ? raw.split("\u2502").slice(1).join("\u2502") : ""
62
+
63
+ // Trace rows: left pane, `·` then the operation name as the first
64
+ // token. (Earlier versions appended `#<hash>`; that's been removed.)
65
+ const rowMatch = leftHalf.match(/^\s+\u00b7\s+(op[A-Z])\b/)
66
+ if (rowMatch) rows.push(rowMatch[1]!)
67
+
68
+ // Selected trace: right pane, line immediately after `TRACE DETAILS`
69
+ // header holds the selected root operation name.
70
+ if (rightHalf.includes("TRACE DETAILS") || leftHalf.includes("TRACE DETAILS")) {
71
+ inDetailsPane = true
72
+ detailsLinesConsumed = 0
73
+ continue
74
+ }
75
+ if (inDetailsPane) {
76
+ detailsLinesConsumed++
77
+ if (detailsLinesConsumed === 1) {
78
+ // The op name row: right pane (if wide) or left pane (if narrow).
79
+ const source = rightHalf || leftHalf
80
+ const opMatch = source.match(/^\s*(\S+)/)
81
+ if (opMatch && opMatch[1] !== "No" && opMatch[1] !== "waiting") {
82
+ selected = opMatch[1]!
83
+ }
84
+ inDetailsPane = false
85
+ }
86
+ }
87
+ }
88
+ return { rows, selected }
89
+ }
90
+
91
+ const SERVICE_NAME = "sort-nav-repro"
92
+
93
+ describe("trace navigation after changing sort", () => {
94
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-sort-repro-"))
95
+ const dbPath = join(tempDir, "telemetry.sqlite")
96
+ const port = 50_000 + Math.floor(Math.random() * 5_000)
97
+ const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
98
+ const lastServicePath = join(tempDir, "last-service.txt")
99
+ let canRun = false
100
+
101
+ beforeAll(async () => {
102
+ canRun = await hasTuistory()
103
+ if (!canRun) return
104
+
105
+ writeFileSync(lastServicePath, SERVICE_NAME)
106
+
107
+ // Seed in a child process so config.ts picks up our DB path fresh.
108
+ const seed = Bun.spawn({
109
+ cmd: ["bun", "run", "src/ui/traceSortNav.repro.seed.ts"],
110
+ cwd: process.cwd(),
111
+ env: { ...process.env, ...daemonEnv, MOTEL_OTEL_ENABLED: "false" },
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ })
115
+ const seedCode = await seed.exited
116
+ if (seedCode !== 0) {
117
+ const err = await new Response(seed.stderr).text()
118
+ throw new Error(`seed failed: ${err}`)
119
+ }
120
+
121
+ await tui(["close", "--session", SESSION])
122
+ // Use a modest height (20 rows) where the 15-trace list must scroll to
123
+ // reveal the bottom rows while still leaving room for the details pane
124
+ // on the right (needed by the test's selected-trace extraction).
125
+ const launch = await tui([
126
+ "launch",
127
+ "bun run src/index.tsx",
128
+ "--session", SESSION,
129
+ "--cols", "120",
130
+ "--rows", "20",
131
+ "--cwd", process.cwd(),
132
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
133
+ "--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
134
+ "--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
135
+ "--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
136
+ "--env", `MOTEL_OTEL_PORT=${port}`,
137
+ "--env", "MOTEL_OTEL_ENABLED=false",
138
+ "--timeout", "15000",
139
+ ])
140
+ if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
141
+ await tui(["wait", "opE", "--session", SESSION, "--timeout", "10000"])
142
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
143
+ }, 60_000)
144
+
145
+ afterAll(async () => {
146
+ if (canRun) {
147
+ await tui(["close", "--session", SESSION])
148
+ const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
149
+ await stop.exited
150
+ }
151
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
152
+ })
153
+
154
+ // Expected orders given the seed (A..O):
155
+ // recent : O N M L K J I H G F E D C B A (newest first)
156
+ // slowest: H L D F J B M E N I A O C G K (durations 200..1 ms)
157
+ const RECENT_ORDER = ["opO", "opN", "opM", "opL", "opK", "opJ", "opI", "opH", "opG", "opF", "opE", "opD", "opC", "opB", "opA"]
158
+ const SLOWEST_ORDER = ["opH", "opL", "opD", "opF", "opJ", "opB", "opM", "opE", "opN", "opI", "opA", "opO", "opC", "opG", "opK"]
159
+
160
+ it("default sort is 'recent' (most recent first), selection starts at the top", async () => {
161
+ if (!canRun) return
162
+ const snap = await snapshot()
163
+ const parsed = listRows(snap)
164
+ if (parsed.selected === null) {
165
+ // Dump raw snapshot to aid diagnosis when the extraction helper misses.
166
+ console.error("--- RAW SNAPSHOT ---\n" + snap + "\n--- END ---")
167
+ }
168
+ expect(parsed.selected).toBe("opO")
169
+ })
170
+
171
+ it("after `s`, the visible order is `slowest` and selection stays on the same trace", async () => {
172
+ if (!canRun) return
173
+ await press("s")
174
+ const { selected } = listRows(await snapshot())
175
+ // opO moves from recent #0 to slowest #11. Selection follows the trace.
176
+ expect(selected).toBe("opO")
177
+ })
178
+
179
+ it("j moves through the SORTED order (not raw data order)", async () => {
180
+ if (!canRun) return
181
+ // Currently selected = opO at slowest index 11 (SLOWEST_ORDER[11] = "opO").
182
+ // Press j repeatedly and each step should land on the next sorted row.
183
+ const startIdx = SLOWEST_ORDER.indexOf("opO")
184
+ for (let offset = 1; offset <= 3; offset++) {
185
+ await press("j")
186
+ const expected = SLOWEST_ORDER[startIdx + offset]
187
+ const { selected } = listRows(await snapshot())
188
+ expect(selected).toBe(expected)
189
+ }
190
+ })
191
+
192
+ it("k moves backward through the sorted order", async () => {
193
+ if (!canRun) return
194
+ // We are 3 rows past opO in SLOWEST_ORDER.
195
+ for (let i = 0; i < 3; i++) await press("k")
196
+ expect(listRows(await snapshot()).selected).toBe("opO")
197
+ })
198
+
199
+ it("G jumps to the bottom of the sorted list, gg to the top", async () => {
200
+ if (!canRun) return
201
+ await press("G")
202
+ expect(listRows(await snapshot()).selected).toBe(SLOWEST_ORDER[SLOWEST_ORDER.length - 1]!)
203
+
204
+ await press("g", "g")
205
+ expect(listRows(await snapshot()).selected).toBe(SLOWEST_ORDER[0]!)
206
+ })
207
+
208
+ it("switching sort back to 'recent' restores recency order and keeps selection on the same trace", async () => {
209
+ if (!canRun) return
210
+ // Currently on SLOWEST_ORDER[0] = "opH". Press `s` twice: slowest → errors → recent.
211
+ await press("s")
212
+ await press("s")
213
+ const { selected } = listRows(await snapshot())
214
+ expect(selected).toBe("opH")
215
+ })
216
+
217
+ it("scrolling: selecting a trace near the bottom brings it into view, and changing sort keeps it in view", async () => {
218
+ if (!canRun) return
219
+ // Jump to the bottom of the recent list (opA), then change sort to slowest.
220
+ // opA is at slowest index 10 — well down the list. Selection must stay on opA
221
+ // AND the viewport must scroll to show it.
222
+ await press("G")
223
+ expect(listRows(await snapshot()).selected).toBe("opA")
224
+
225
+ await press("s")
226
+ const after = listRows(await snapshot())
227
+ expect(after.selected).toBe("opA")
228
+ // The row for opA must be visible in the rendered list.
229
+ expect(after.rows).toContain("opA")
230
+ })
231
+ })
@@ -0,0 +1,89 @@
1
+ import { useAtom } from "@effect/atom-react"
2
+ import { useEffect } from "react"
3
+ import {
4
+ attrFacetStateAtom,
5
+ attrPickerModeAtom,
6
+ initialAttrFacetState,
7
+ selectedTraceServiceAtom,
8
+ } from "./atoms.ts"
9
+ import {
10
+ getCachedFacetKeys,
11
+ getCachedFacetValues,
12
+ refreshTraceAttributeKeys,
13
+ refreshTraceAttributeValues,
14
+ } from "./loaders.ts"
15
+
16
+ // Drive the picker's data state from (pickerMode, service, selectedKey).
17
+ //
18
+ // Strategy: stale-while-revalidate. On reopen we publish whatever the
19
+ // module-level cache has instantly (no "loading…" flash), then kick off a
20
+ // background revalidation. The first time we see a (service, key) tuple
21
+ // we still show `loading` so the UI has something to say. The module-level
22
+ // caches in `loaders.ts` mean a service-change pre-warm can fill the cache
23
+ // before the user ever presses `f`.
24
+ export const useAttrFilterPicker = (selectedKey: string | null) => {
25
+ const [pickerMode] = useAtom(attrPickerModeAtom)
26
+ const [service] = useAtom(selectedTraceServiceAtom)
27
+ const [, setFacetState] = useAtom(attrFacetStateAtom)
28
+
29
+ useEffect(() => {
30
+ if (pickerMode === "off" || !service) {
31
+ setFacetState(initialAttrFacetState)
32
+ return
33
+ }
34
+ let cancelled = false
35
+ const publishReady = (key: string | null, data: readonly { readonly value: string; readonly count: number }[]) => {
36
+ setFacetState({ status: "ready", key, data, error: null })
37
+ }
38
+ const publishLoading = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[] = []) => {
39
+ setFacetState({ status: "loading", key, data: previous, error: null })
40
+ }
41
+ const publishError = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[], err: unknown) => {
42
+ setFacetState({
43
+ status: "error",
44
+ key,
45
+ data: previous,
46
+ error: err instanceof Error ? err.message : String(err),
47
+ })
48
+ }
49
+
50
+ if (pickerMode === "keys") {
51
+ const cached = getCachedFacetKeys(service)
52
+ if (cached) {
53
+ publishReady(null, cached.data)
54
+ } else {
55
+ publishLoading(null)
56
+ }
57
+ refreshTraceAttributeKeys(service)
58
+ .then((entry) => {
59
+ if (cancelled) return
60
+ publishReady(null, entry.data)
61
+ })
62
+ .catch((err) => {
63
+ if (cancelled) return
64
+ publishError(null, cached?.data ?? [], err)
65
+ })
66
+ } else if (selectedKey) {
67
+ const cached = getCachedFacetValues(service, selectedKey)
68
+ if (cached) {
69
+ publishReady(selectedKey, cached.data)
70
+ } else {
71
+ publishLoading(selectedKey)
72
+ }
73
+ refreshTraceAttributeValues(service, selectedKey)
74
+ .then((entry) => {
75
+ if (cancelled) return
76
+ publishReady(selectedKey, entry.data)
77
+ })
78
+ .catch((err) => {
79
+ if (cancelled) return
80
+ publishError(selectedKey, cached?.data ?? [], err)
81
+ })
82
+ } else {
83
+ // values mode with no key yet — just show empty state.
84
+ publishReady(null, [])
85
+ }
86
+
87
+ return () => { cancelled = true }
88
+ }, [pickerMode, service, selectedKey, setFacetState])
89
+ }