@kitlangton/motel 0.2.1 → 0.2.4
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/package.json +3 -3
- package/src/App.tsx +1 -1
- package/src/daemon.test.ts +70 -0
- package/src/daemon.ts +65 -34
- package/src/localServer.ts +3 -7
- package/src/services/AsyncIngest.ts +20 -4
- package/src/services/TelemetryStore.ts +128 -110
- 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 +1 -1
- package/src/ui/useKeyboardNav.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/motel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -65,8 +65,8 @@
|
|
|
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
71
|
"typecheck": "tsc --noEmit",
|
|
72
72
|
"prepublishOnly": "bun run web:build"
|
package/src/App.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
|
|
|
28
28
|
import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
|
|
29
29
|
import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
|
|
30
30
|
import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
|
|
31
|
-
import { getVisibleSpans } from "./ui/
|
|
31
|
+
import { getVisibleSpans } from "./ui/waterfallModel.ts"
|
|
32
32
|
|
|
33
33
|
const NOTICE_TIMEOUT_MS = 2500
|
|
34
34
|
|
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
|
})
|
package/src/daemon.ts
CHANGED
|
@@ -2,14 +2,12 @@ import * as fs from "node:fs"
|
|
|
2
2
|
import { promises as fsp } from "node:fs"
|
|
3
3
|
import * as path from "node:path"
|
|
4
4
|
import { Effect } from "effect"
|
|
5
|
-
import { isAlive, listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry } from "./registry.js"
|
|
5
|
+
import { isAlive, listAliveEntries, MOTEL_SERVICE_ID, MOTEL_VERSION, type RegistryEntry } from "./registry.js"
|
|
6
6
|
|
|
7
7
|
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
|
|
8
|
-
const DEFAULT_RUNTIME_DIR = path.join(DEFAULT_REPO_ROOT, ".motel-data")
|
|
9
|
-
const DEFAULT_DATABASE_PATH = path.join(DEFAULT_RUNTIME_DIR, "telemetry.sqlite")
|
|
10
8
|
const DEFAULT_HOST = "127.0.0.1"
|
|
11
9
|
const DEFAULT_PORT = 27686
|
|
12
|
-
const START_TIMEOUT_MS =
|
|
10
|
+
const START_TIMEOUT_MS = 30_000
|
|
13
11
|
const STOP_TIMEOUT_MS = 10_000
|
|
14
12
|
const LOCK_TIMEOUT_MS = 10_000
|
|
15
13
|
const POLL_INTERVAL_MS = 150
|
|
@@ -44,6 +42,8 @@ type LockShape = {
|
|
|
44
42
|
|
|
45
43
|
type DaemonConfig = {
|
|
46
44
|
readonly repoRoot: string
|
|
45
|
+
readonly serverEntry: string
|
|
46
|
+
readonly workdir: string
|
|
47
47
|
readonly runtimeDir: string
|
|
48
48
|
readonly databasePath: string
|
|
49
49
|
readonly logPath: string
|
|
@@ -79,6 +79,7 @@ export type DaemonManager = {
|
|
|
79
79
|
|
|
80
80
|
type DaemonOptions = {
|
|
81
81
|
readonly repoRoot?: string
|
|
82
|
+
readonly workdir?: string
|
|
82
83
|
readonly runtimeDir?: string
|
|
83
84
|
readonly databasePath?: string
|
|
84
85
|
readonly host?: string
|
|
@@ -96,12 +97,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
96
97
|
|
|
97
98
|
const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
98
99
|
const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
|
|
99
|
-
const
|
|
100
|
+
const workdir = path.resolve(options.workdir ?? process.cwd())
|
|
101
|
+
const runtimeDir = path.resolve(options.runtimeDir ?? path.join(workdir, ".motel-data"))
|
|
100
102
|
const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
|
|
101
103
|
const host = options.host ?? DEFAULT_HOST
|
|
102
104
|
const port = options.port ?? DEFAULT_PORT
|
|
103
105
|
return {
|
|
104
106
|
repoRoot,
|
|
107
|
+
serverEntry: path.join(repoRoot, "src/server.ts"),
|
|
108
|
+
workdir,
|
|
105
109
|
runtimeDir,
|
|
106
110
|
databasePath,
|
|
107
111
|
logPath: path.join(runtimeDir, "daemon.log"),
|
|
@@ -112,16 +116,14 @@ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
return normalizedCwd === normalizedWorkdir || normalizedCwd.startsWith(normalizedWorkdir)
|
|
119
|
+
const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
|
|
120
|
+
const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
|
|
121
|
+
const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
|
|
122
|
+
return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
|
|
125
|
+
const pickByWorkdir = (entries: readonly RegistryEntry[], targetWorkdir: string) => {
|
|
126
|
+
const withSep = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
|
|
125
127
|
return entries
|
|
126
128
|
.filter((entry) => {
|
|
127
129
|
const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
|
|
@@ -130,8 +132,6 @@ const pickByCwd = (entries: readonly RegistryEntry[]) => {
|
|
|
130
132
|
.sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
const readRegistryEntry = () => pickByCwd(listAliveEntries())
|
|
134
|
-
|
|
135
135
|
const expectedEnv = (config: DaemonConfig) => ({
|
|
136
136
|
MOTEL_OTEL_BASE_URL: config.baseUrl,
|
|
137
137
|
MOTEL_OTEL_QUERY_URL: config.baseUrl,
|
|
@@ -145,6 +145,7 @@ const expectedEnv = (config: DaemonConfig) => ({
|
|
|
145
145
|
export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
|
|
146
146
|
const config = resolveConfig(options)
|
|
147
147
|
const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
|
|
148
|
+
const readRegistryEntry = () => pickByWorkdir(listAliveEntries(), config.workdir)
|
|
148
149
|
|
|
149
150
|
const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
|
|
150
151
|
try {
|
|
@@ -156,12 +157,39 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
const startupMarkers = [`Listening on ${config.baseUrl}`, `motel local telemetry server listening on ${config.baseUrl}`]
|
|
161
|
+
|
|
162
|
+
const readLogSince = async (offset: number) => {
|
|
163
|
+
try {
|
|
164
|
+
const raw = await fsp.readFile(config.logPath, "utf8")
|
|
165
|
+
return raw.slice(offset)
|
|
166
|
+
} catch {
|
|
167
|
+
return ""
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const detectStartedFromLog = async (pid: number, offset: number): Promise<HealthShape | null> => {
|
|
172
|
+
if (!isAlive(pid)) return null
|
|
173
|
+
const tail = await readLogSince(offset)
|
|
174
|
+
if (!startupMarkers.some((marker) => tail.includes(marker))) return null
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
service: MOTEL_SERVICE_ID,
|
|
178
|
+
databasePath: config.databasePath,
|
|
179
|
+
pid,
|
|
180
|
+
url: config.baseUrl,
|
|
181
|
+
workdir: config.workdir,
|
|
182
|
+
startedAt: new Date().toISOString(),
|
|
183
|
+
version: MOTEL_VERSION,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
159
187
|
const describeManagedMismatch = (health: HealthShape) => {
|
|
160
188
|
if (health.service !== MOTEL_SERVICE_ID) {
|
|
161
189
|
return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
|
|
162
190
|
}
|
|
163
|
-
if (!
|
|
164
|
-
return `Port ${config.port} is serving motel for ${health.workdir}, not ${
|
|
191
|
+
if (!workdirMatches(config.workdir, health.workdir)) {
|
|
192
|
+
return `Port ${config.port} is serving motel for ${health.workdir}, not ${config.workdir}.`
|
|
165
193
|
}
|
|
166
194
|
if (health.databasePath !== config.databasePath) {
|
|
167
195
|
return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
|
|
@@ -181,8 +209,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
181
209
|
* when absent we skip the DB check rather than refusing to adopt.
|
|
182
210
|
*/
|
|
183
211
|
const describeRegistryMismatch = (entry: RegistryEntry): string | null => {
|
|
184
|
-
if (!
|
|
185
|
-
return `Port ${config.port} is serving motel for ${entry.workdir}, not ${
|
|
212
|
+
if (!workdirMatches(config.workdir, entry.workdir)) {
|
|
213
|
+
return `Port ${config.port} is serving motel for ${entry.workdir}, not ${config.workdir}.`
|
|
186
214
|
}
|
|
187
215
|
if (entry.databasePath && entry.databasePath !== config.databasePath) {
|
|
188
216
|
return `Port ${config.port} is serving motel with ${entry.databasePath}, expected ${config.databasePath}.`
|
|
@@ -217,7 +245,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
217
245
|
workdir: entry.workdir,
|
|
218
246
|
startedAt: entry.startedAt,
|
|
219
247
|
version: entry.version,
|
|
220
|
-
sameWorkdir:
|
|
248
|
+
sameWorkdir: workdirMatches(config.workdir, entry.workdir),
|
|
221
249
|
reason: mismatch,
|
|
222
250
|
logPath: config.logPath,
|
|
223
251
|
lockPath: config.lockPath,
|
|
@@ -276,7 +304,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
276
304
|
return fs.openSync(config.logPath, "a")
|
|
277
305
|
}
|
|
278
306
|
|
|
279
|
-
const waitForHealthy = async (pid: number) => {
|
|
307
|
+
const waitForHealthy = async (pid: number, logOffset: number) => {
|
|
280
308
|
const deadline = Date.now() + START_TIMEOUT_MS
|
|
281
309
|
while (Date.now() < deadline) {
|
|
282
310
|
const health = await fetchHealth()
|
|
@@ -285,6 +313,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
285
313
|
if (!mismatch) return health
|
|
286
314
|
throw new Error(mismatch)
|
|
287
315
|
}
|
|
316
|
+
const started = await detectStartedFromLog(pid, logOffset)
|
|
317
|
+
if (started) return started
|
|
288
318
|
if (!isAlive(pid)) {
|
|
289
319
|
// The spawned child is gone. Before declaring failure,
|
|
290
320
|
// do one patient probe: the child may have died from
|
|
@@ -316,7 +346,9 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
316
346
|
while (Date.now() < deadline) {
|
|
317
347
|
if (!isAlive(pid)) return
|
|
318
348
|
const health = await fetchHealth()
|
|
319
|
-
if (
|
|
349
|
+
if (health && health.pid !== pid) return
|
|
350
|
+
const registry = readRegistryEntry()
|
|
351
|
+
if (!health && (!registry || registry.pid !== pid)) return
|
|
320
352
|
await sleep(POLL_INTERVAL_MS)
|
|
321
353
|
}
|
|
322
354
|
|
|
@@ -352,7 +384,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
352
384
|
workdir: registry?.workdir ?? null,
|
|
353
385
|
startedAt: registry?.startedAt ?? null,
|
|
354
386
|
version: registry?.version ?? null,
|
|
355
|
-
sameWorkdir: registry ?
|
|
387
|
+
sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
|
|
356
388
|
reason: registry ? "Registry entry exists but daemon is not healthy." : null,
|
|
357
389
|
logPath: config.logPath,
|
|
358
390
|
lockPath: config.lockPath,
|
|
@@ -371,7 +403,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
371
403
|
workdir: health.workdir,
|
|
372
404
|
startedAt: health.startedAt,
|
|
373
405
|
version: health.version,
|
|
374
|
-
sameWorkdir:
|
|
406
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
375
407
|
reason: mismatch,
|
|
376
408
|
logPath: config.logPath,
|
|
377
409
|
lockPath: config.lockPath,
|
|
@@ -404,10 +436,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
404
436
|
}
|
|
405
437
|
|
|
406
438
|
const logFd = await openLogFile()
|
|
439
|
+
const logOffset = fs.fstatSync(logFd).size
|
|
407
440
|
try {
|
|
408
441
|
const proc = Bun.spawn({
|
|
409
|
-
cmd: [process.execPath, "run",
|
|
410
|
-
cwd: config.
|
|
442
|
+
cmd: [process.execPath, "run", config.serverEntry],
|
|
443
|
+
cwd: config.workdir,
|
|
411
444
|
detached: true,
|
|
412
445
|
env: {
|
|
413
446
|
...process.env,
|
|
@@ -425,7 +458,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
425
458
|
throw new Error("Daemon failed to spawn.")
|
|
426
459
|
}
|
|
427
460
|
|
|
428
|
-
const health = await waitForHealthy(spawnedPid)
|
|
461
|
+
const health = await waitForHealthy(spawnedPid, logOffset)
|
|
429
462
|
return {
|
|
430
463
|
running: true,
|
|
431
464
|
managed: true,
|
|
@@ -436,7 +469,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
436
469
|
workdir: health.workdir,
|
|
437
470
|
startedAt: health.startedAt,
|
|
438
471
|
version: health.version,
|
|
439
|
-
sameWorkdir:
|
|
472
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
440
473
|
reason: null,
|
|
441
474
|
logPath: config.logPath,
|
|
442
475
|
lockPath: config.lockPath,
|
|
@@ -495,9 +528,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
495
528
|
}
|
|
496
529
|
}
|
|
497
530
|
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
export const
|
|
501
|
-
export const
|
|
502
|
-
export const ensureManagedDaemon = defaultManager.ensure
|
|
503
|
-
export const stopManagedDaemon = defaultManager.stop
|
|
531
|
+
export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
|
|
532
|
+
export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
|
|
533
|
+
export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
|
|
534
|
+
export const stopManagedDaemon = Effect.suspend(() => createDaemonManager().stop)
|
package/src/localServer.ts
CHANGED
|
@@ -9,7 +9,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
|
|
9
9
|
import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
|
|
10
10
|
import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
|
|
11
11
|
import { MotelHttpApi } from "./httpApi.js"
|
|
12
|
-
import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries
|
|
12
|
+
import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
|
|
13
13
|
import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
|
|
14
14
|
import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
|
|
15
15
|
import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
|
|
@@ -72,14 +72,10 @@ const parseLookbackMinutes = (value: string | null, fallback: number) => {
|
|
|
72
72
|
const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
|
|
73
73
|
|
|
74
74
|
const attributeFiltersFromQuery = (url: URL) =>
|
|
75
|
-
attributeFiltersFromEntries(
|
|
76
|
-
[...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
|
|
77
|
-
)
|
|
75
|
+
attributeFiltersFromEntries(url.searchParams.entries())
|
|
78
76
|
|
|
79
77
|
const attributeContainsFiltersFromQuery = (url: URL) =>
|
|
80
|
-
attributeContainsFiltersFromEntries(
|
|
81
|
-
[...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
|
|
82
|
-
)
|
|
78
|
+
attributeContainsFiltersFromEntries(url.searchParams.entries())
|
|
83
79
|
|
|
84
80
|
type CursorShape =
|
|
85
81
|
| { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import * as BunWorker from "@effect/platform-bun/BunWorker"
|
|
23
|
-
import { Context, Layer } from "effect"
|
|
23
|
+
import { Context, Effect, Layer, Scope } from "effect"
|
|
24
24
|
import * as RpcClient from "effect/unstable/rpc/RpcClient"
|
|
25
25
|
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
|
|
26
26
|
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
|
|
27
|
+
import type { WorkerError } from "effect/unstable/workers/WorkerError"
|
|
27
28
|
import { IngestRpcs } from "./ingestRpc.ts"
|
|
28
29
|
|
|
29
30
|
// RpcClient.make always surfaces RpcClientError in addition to the
|
|
@@ -33,7 +34,7 @@ import { IngestRpcs } from "./ingestRpc.ts"
|
|
|
33
34
|
// unrelated structural mismatches.
|
|
34
35
|
export class AsyncIngest extends Context.Service<
|
|
35
36
|
AsyncIngest,
|
|
36
|
-
RpcClient.FromGroup<typeof IngestRpcs, RpcClientError>
|
|
37
|
+
RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
|
|
37
38
|
>()("@motel/AsyncIngest") {}
|
|
38
39
|
|
|
39
40
|
// Protocol: RpcClient.layerProtocolWorker manages a worker pool and
|
|
@@ -48,5 +49,20 @@ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
|
|
|
48
49
|
|
|
49
50
|
export const AsyncIngestLive = Layer.effect(
|
|
50
51
|
AsyncIngest,
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
Effect.gen(function*() {
|
|
53
|
+
const scope = yield* Scope.Scope
|
|
54
|
+
// Keep daemon startup cheap: creating the RPC client here would eagerly
|
|
55
|
+
// spawn the worker and make /api/health wait on the worker's SQLite
|
|
56
|
+
// bootstrap. Cache a lazy initializer instead so the worker only starts
|
|
57
|
+
// on the first ingest request, but is still shared thereafter.
|
|
58
|
+
const getClient = yield* RpcClient.make(IngestRpcs).pipe(
|
|
59
|
+
Effect.provide(WorkerProtocol),
|
|
60
|
+
Effect.cached,
|
|
61
|
+
)
|
|
62
|
+
const withScope = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, Scope.Scope, scope)
|
|
63
|
+
return {
|
|
64
|
+
ingestTraces: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestTraces(input, options)),
|
|
65
|
+
ingestLogs: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestLogs(input, options)),
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
@@ -7,6 +7,9 @@ import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsIt
|
|
|
7
7
|
import { AI_ATTR_MAP, AI_FTS_KEYS, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
|
|
8
8
|
import { attributeMap, nanosToMilliseconds, parseAnyValue, spanKindLabel, spanStatusLabel, stringifyValue, type OtlpLogExportRequest, type OtlpTraceExportRequest } from "../otlp.js"
|
|
9
9
|
|
|
10
|
+
const isSqliteLockError = (error: unknown) =>
|
|
11
|
+
error instanceof Error && /(database is locked|database table is locked|SQLITE_BUSY)/i.test(error.message)
|
|
12
|
+
|
|
10
13
|
interface SpanRow {
|
|
11
14
|
readonly trace_id: string
|
|
12
15
|
readonly span_id: string
|
|
@@ -502,108 +505,110 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
502
505
|
PRAGMA mmap_size = 268435456;
|
|
503
506
|
`)
|
|
504
507
|
} else {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
508
|
+
db.exec(`
|
|
509
|
+
-- Bump cache above the 2MB default. 64MB fits most hot index pages
|
|
510
|
+
-- (trace_summaries, spans, span_attributes indexes) in RAM even on
|
|
511
|
+
-- multi-GB databases, cutting cold-read latency meaningfully on
|
|
512
|
+
-- picker / search queries that sweep the index.
|
|
513
|
+
PRAGMA cache_size = -65536;
|
|
514
|
+
-- Let SQLite memory-map the first 256MB of the file. This is a
|
|
515
|
+
-- cheap way to avoid read() syscalls on hot pages and lets the OS
|
|
516
|
+
-- page cache serve index lookups directly. Safe on macOS and Linux;
|
|
517
|
+
-- SQLite silently caps at actual file size for smaller DBs.
|
|
518
|
+
PRAGMA mmap_size = 268435456;
|
|
519
|
+
`)
|
|
520
|
+
try {
|
|
521
|
+
db.exec(`
|
|
522
|
+
PRAGMA journal_mode = WAL;
|
|
523
|
+
PRAGMA synchronous = NORMAL;
|
|
524
|
+
PRAGMA temp_store = MEMORY;
|
|
525
|
+
-- WAL checkpoint automatically when it grows past ~16MB. Without
|
|
526
|
+
-- this the WAL happily runs into the hundreds of MB and queries
|
|
527
|
+
-- start paying the cost of walking the WAL on every read.
|
|
528
|
+
PRAGMA wal_autocheckpoint = 4000;
|
|
529
|
+
|
|
530
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
531
|
+
trace_id TEXT NOT NULL,
|
|
532
|
+
span_id TEXT NOT NULL,
|
|
533
|
+
parent_span_id TEXT,
|
|
534
|
+
service_name TEXT NOT NULL,
|
|
535
|
+
scope_name TEXT,
|
|
536
|
+
operation_name TEXT NOT NULL,
|
|
537
|
+
kind TEXT,
|
|
538
|
+
start_time_ms INTEGER NOT NULL,
|
|
539
|
+
end_time_ms INTEGER NOT NULL,
|
|
540
|
+
duration_ms REAL NOT NULL,
|
|
541
|
+
status TEXT NOT NULL,
|
|
542
|
+
attributes_json TEXT NOT NULL,
|
|
543
|
+
resource_json TEXT NOT NULL,
|
|
544
|
+
events_json TEXT NOT NULL,
|
|
545
|
+
PRIMARY KEY (trace_id, span_id)
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
CREATE INDEX IF NOT EXISTS idx_spans_service_time ON spans(service_name, start_time_ms DESC);
|
|
549
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace_time ON spans(trace_id, start_time_ms ASC);
|
|
550
|
+
CREATE INDEX IF NOT EXISTS idx_spans_span_id ON spans(span_id);
|
|
551
|
+
CREATE INDEX IF NOT EXISTS idx_spans_status_time ON spans(status, start_time_ms DESC);
|
|
552
|
+
|
|
553
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
554
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
555
|
+
trace_id TEXT,
|
|
556
|
+
span_id TEXT,
|
|
557
|
+
service_name TEXT NOT NULL,
|
|
558
|
+
scope_name TEXT,
|
|
559
|
+
severity_text TEXT NOT NULL,
|
|
560
|
+
timestamp_ms INTEGER NOT NULL,
|
|
561
|
+
body TEXT NOT NULL,
|
|
562
|
+
attributes_json TEXT NOT NULL,
|
|
563
|
+
resource_json TEXT NOT NULL
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
CREATE INDEX IF NOT EXISTS idx_logs_service_time ON logs(service_name, timestamp_ms DESC);
|
|
567
|
+
CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
|
|
568
|
+
CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
|
|
569
|
+
CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
|
|
570
|
+
|
|
571
|
+
CREATE TABLE IF NOT EXISTS trace_summaries (
|
|
572
|
+
trace_id TEXT PRIMARY KEY,
|
|
573
|
+
service_name TEXT NOT NULL,
|
|
574
|
+
root_operation_name TEXT NOT NULL,
|
|
575
|
+
started_at_ms INTEGER NOT NULL,
|
|
576
|
+
ended_at_ms INTEGER NOT NULL,
|
|
577
|
+
active_span_count INTEGER NOT NULL DEFAULT 0,
|
|
578
|
+
duration_ms REAL NOT NULL,
|
|
579
|
+
span_count INTEGER NOT NULL,
|
|
580
|
+
error_count INTEGER NOT NULL
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
CREATE INDEX IF NOT EXISTS idx_trace_summaries_started_at ON trace_summaries(started_at_ms DESC, trace_id DESC);
|
|
584
|
+
CREATE INDEX IF NOT EXISTS idx_trace_summaries_service_started_at ON trace_summaries(service_name, started_at_ms DESC, trace_id DESC);
|
|
585
|
+
CREATE INDEX IF NOT EXISTS idx_trace_summaries_duration ON trace_summaries(duration_ms DESC);
|
|
586
|
+
|
|
587
|
+
CREATE TABLE IF NOT EXISTS span_attributes (
|
|
588
|
+
trace_id TEXT NOT NULL,
|
|
589
|
+
span_id TEXT NOT NULL,
|
|
590
|
+
key TEXT NOT NULL,
|
|
591
|
+
value TEXT NOT NULL,
|
|
592
|
+
PRIMARY KEY (trace_id, span_id, key)
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
CREATE INDEX IF NOT EXISTS idx_span_attributes_key_value ON span_attributes(key, value, trace_id, span_id);
|
|
596
|
+
CREATE INDEX IF NOT EXISTS idx_span_attributes_trace_span ON span_attributes(trace_id, span_id);
|
|
597
|
+
|
|
598
|
+
CREATE TABLE IF NOT EXISTS log_attributes (
|
|
599
|
+
log_id INTEGER NOT NULL,
|
|
600
|
+
key TEXT NOT NULL,
|
|
601
|
+
value TEXT NOT NULL,
|
|
602
|
+
PRIMARY KEY (log_id, key)
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
CREATE INDEX IF NOT EXISTS idx_log_attributes_key_value ON log_attributes(key, value, log_id);
|
|
606
|
+
CREATE INDEX IF NOT EXISTS idx_log_attributes_log_id ON log_attributes(log_id);
|
|
607
|
+
`)
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (!isSqliteLockError(err)) throw err
|
|
610
|
+
console.warn(`motel: writer bootstrap skipped during startup: ${(err as Error).message}`)
|
|
611
|
+
}
|
|
607
612
|
}
|
|
608
613
|
|
|
609
614
|
// Tables detected at runtime. For writer connections these flags are
|
|
@@ -726,6 +731,12 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
726
731
|
// ANALYZE / optimize failures are never fatal — queries still work,
|
|
727
732
|
// they just run with default row estimates.
|
|
728
733
|
}
|
|
734
|
+
// Longer busy timeout: the ingest worker holds the write lock for up
|
|
735
|
+
// to a few seconds during big OTLP batches, and the daemon's retention
|
|
736
|
+
// passes can do the same. Apply this AFTER startup maintenance so
|
|
737
|
+
// lock-conflicted bootstrap steps fail fast instead of stalling health
|
|
738
|
+
// for the full 15s timeout.
|
|
739
|
+
try { db.exec(`PRAGMA busy_timeout = 15000;`) } catch { /* ignore */ }
|
|
729
740
|
} // end: if (!opts.readonly) writer init
|
|
730
741
|
|
|
731
742
|
const insertSpan = db.query(`
|
|
@@ -774,14 +785,15 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
774
785
|
GROUP BY trace_id
|
|
775
786
|
`)
|
|
776
787
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
788
|
+
const reconcileTraceSummaries = Effect.sync(() => {
|
|
789
|
+
try {
|
|
790
|
+
db.query(`DELETE FROM trace_summaries`).run()
|
|
791
|
+
rebuildTraceSummaries.run()
|
|
792
|
+
} catch (err) {
|
|
793
|
+
if (!isSqliteLockError(err)) throw err
|
|
794
|
+
console.warn(`motel: trace summary rebuild skipped during startup: ${(err as Error).message}`)
|
|
795
|
+
}
|
|
796
|
+
})
|
|
785
797
|
|
|
786
798
|
const deleteSpanAttributes = db.query(`DELETE FROM span_attributes WHERE trace_id = ? AND span_id = ?`)
|
|
787
799
|
const insertSpanAttribute = db.query(`INSERT INTO span_attributes (trace_id, span_id, key, value) VALUES (?, ?, ?, ?)`)
|
|
@@ -887,6 +899,12 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
|
|
|
887
899
|
// daemon). The ingest worker and TUI skip it to avoid two writers
|
|
888
900
|
// competing for the write lock with overlapping DELETE passes.
|
|
889
901
|
if (opts.runRetention) {
|
|
902
|
+
// Reconcile any summary drift from interrupted ingests, but do it
|
|
903
|
+
// after the server becomes healthy. Running this synchronously at
|
|
904
|
+
// open can sit behind another writer's lock for ~15s and make the
|
|
905
|
+
// daemon look hung even though the port is already bound.
|
|
906
|
+
yield* Effect.forkScoped(reconcileTraceSummaries)
|
|
907
|
+
|
|
890
908
|
// Enable incremental vacuum so retention can reclaim freed
|
|
891
909
|
// pages over time instead of needing a stop-the-world VACUUM.
|
|
892
910
|
// Idempotent: repeat calls after the first are no-ops.
|
|
@@ -212,6 +212,7 @@ const StoryApp = () => {
|
|
|
212
212
|
bodyLines={bodyLines}
|
|
213
213
|
paneWidth={w}
|
|
214
214
|
/>
|
|
215
|
+
<Divider width={contentWidth} />
|
|
215
216
|
<box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
|
|
216
217
|
<TextLine>
|
|
217
218
|
<span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
|
package/src/ui/AiChatView.tsx
CHANGED
|
@@ -48,6 +48,16 @@ const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): strin
|
|
|
48
48
|
return colors.text
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const splitToolRowText = (text: string): { readonly head: string; readonly tail: string | null } => {
|
|
52
|
+
const match = text.match(/\s{2,}/)
|
|
53
|
+
const sep = match?.index ?? -1
|
|
54
|
+
if (sep < 0) return { head: text, tail: null }
|
|
55
|
+
return {
|
|
56
|
+
head: text.slice(0, sep),
|
|
57
|
+
tail: text.slice(sep + match![0].length),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
52
62
|
|
|
53
63
|
const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
|
|
@@ -151,15 +161,16 @@ export const AiChatView = ({
|
|
|
151
161
|
}) => {
|
|
152
162
|
const rows = useMemo(() => buildChatListRows(chunks), [chunks])
|
|
153
163
|
const selectable = useMemo(() => chunkRows(rows), [rows])
|
|
164
|
+
const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk] as const)), [chunks])
|
|
165
|
+
const selectedOrdinal = useMemo(
|
|
166
|
+
() => selectedChunkId ? selectable.findIndex((row) => row.chunkId === selectedChunkId) : -1,
|
|
167
|
+
[selectable, selectedChunkId],
|
|
168
|
+
)
|
|
154
169
|
const [scrollOffset, setScrollOffset] = useState(0)
|
|
155
170
|
|
|
156
|
-
const selectedChunk = useMemo(
|
|
157
|
-
() => selectedChunkId ? chunks.find((chunk) => chunk.id === selectedChunkId) ?? null : null,
|
|
158
|
-
[chunks, selectedChunkId],
|
|
159
|
-
)
|
|
160
171
|
const detailChunk = useMemo(
|
|
161
|
-
() => detailChunkId ?
|
|
162
|
-
[
|
|
172
|
+
() => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
|
|
173
|
+
[chunkById, detailChunkId],
|
|
163
174
|
)
|
|
164
175
|
|
|
165
176
|
const selectedRowIndex = useMemo(
|
|
@@ -201,7 +212,7 @@ export const AiChatView = ({
|
|
|
201
212
|
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
202
213
|
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
203
214
|
const visible = rows.slice(offset, offset + bodyLines)
|
|
204
|
-
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1,
|
|
215
|
+
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
|
|
205
216
|
const handleListWheel = (event: MouseScrollEvent) => {
|
|
206
217
|
if (detailChunk) return
|
|
207
218
|
const delta = scrollDelta(event)
|
|
@@ -257,18 +268,23 @@ export const AiChatView = ({
|
|
|
257
268
|
</TextLine>
|
|
258
269
|
)
|
|
259
270
|
}
|
|
260
|
-
const chunk =
|
|
271
|
+
const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
|
|
261
272
|
const isSelected = row.chunkId === selectedChunkId
|
|
262
273
|
const prefix = rowPrefix(chunk)
|
|
263
274
|
const meta = row.meta ?? ""
|
|
264
275
|
const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
|
|
265
276
|
const display = truncateText(row.text, textWidth)
|
|
266
277
|
const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
|
|
278
|
+
const toolLike = chunk?.kind === "tool-call" || chunk?.kind === "tool-result"
|
|
279
|
+
const { head, tail } = toolLike ? splitToolRowText(display) : { head: display, tail: null }
|
|
280
|
+
const headColor = rowTextColor(chunk, row.role, isSelected)
|
|
281
|
+
const tailColor = isSelected ? colors.muted : colors.separator
|
|
267
282
|
return (
|
|
268
283
|
<box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
|
|
269
284
|
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
270
285
|
<span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
|
|
271
|
-
<span fg={
|
|
286
|
+
<span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
|
|
287
|
+
{tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
|
|
272
288
|
{meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
|
|
273
289
|
</TextLine>
|
|
274
290
|
</box>
|
|
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
|
|
|
5
5
|
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
6
|
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
7
|
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
8
|
-
import type { LoadStatus
|
|
8
|
+
import type { LoadStatus } from "./state.ts"
|
|
9
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -24,13 +24,12 @@ export const TraceDetailsPane = ({
|
|
|
24
24
|
traceSummary,
|
|
25
25
|
traceStatus,
|
|
26
26
|
traceError,
|
|
27
|
-
|
|
27
|
+
traceLogCount,
|
|
28
28
|
contentWidth,
|
|
29
29
|
bodyLines,
|
|
30
30
|
paneWidth,
|
|
31
31
|
selectedSpanIndex,
|
|
32
32
|
collapsedSpanIds,
|
|
33
|
-
focused = false,
|
|
34
33
|
onSelectSpan,
|
|
35
34
|
waterfallFilterMode,
|
|
36
35
|
waterfallFilterText,
|
|
@@ -39,13 +38,12 @@ export const TraceDetailsPane = ({
|
|
|
39
38
|
traceSummary: TraceSummaryItem | null
|
|
40
39
|
traceStatus: LoadStatus
|
|
41
40
|
traceError: string | null
|
|
42
|
-
|
|
41
|
+
traceLogCount: number
|
|
43
42
|
contentWidth: number
|
|
44
43
|
bodyLines: number
|
|
45
44
|
paneWidth: number
|
|
46
45
|
selectedSpanIndex: number | null
|
|
47
46
|
collapsedSpanIds: ReadonlySet<string>
|
|
48
|
-
focused?: boolean
|
|
49
47
|
onSelectSpan: (index: number) => void
|
|
50
48
|
waterfallFilterMode: boolean
|
|
51
49
|
waterfallFilterText: string
|
|
@@ -54,20 +52,6 @@ export const TraceDetailsPane = ({
|
|
|
54
52
|
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
55
53
|
[trace, collapsedSpanIds],
|
|
56
54
|
)
|
|
57
|
-
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
58
|
-
const traceLogCount = traceLogsState.data.length
|
|
59
|
-
const spanLogCounts = useMemo(() => {
|
|
60
|
-
const counts = new Map<string, number>()
|
|
61
|
-
for (const log of traceLogsState.data) {
|
|
62
|
-
if (!log.spanId) continue
|
|
63
|
-
counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
|
|
64
|
-
}
|
|
65
|
-
return counts
|
|
66
|
-
}, [traceLogsState.data])
|
|
67
|
-
const selectedSpanLogs = useMemo(
|
|
68
|
-
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
69
|
-
[selectedSpan, traceLogsState.data],
|
|
70
|
-
)
|
|
71
55
|
const matchingSpanIds = useMemo(
|
|
72
56
|
() => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
|
|
73
57
|
[trace, waterfallFilterText],
|
|
@@ -144,14 +128,12 @@ export const TraceDetailsPane = ({
|
|
|
144
128
|
</box>
|
|
145
129
|
) : null}
|
|
146
130
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
bodyLines={waterfallBodyLines}
|
|
154
|
-
selectedSpanIndex={selectedSpanIndex}
|
|
131
|
+
<WaterfallTimeline
|
|
132
|
+
trace={trace}
|
|
133
|
+
filteredSpans={filteredSpans}
|
|
134
|
+
contentWidth={contentWidth}
|
|
135
|
+
bodyLines={waterfallBodyLines}
|
|
136
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
155
137
|
collapsedSpanIds={collapsedSpanIds}
|
|
156
138
|
matchingSpanIds={matchingSpanIds}
|
|
157
139
|
onSelectSpan={onSelectSpan}
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useLayoutEffect,
|
|
1
|
+
import { memo, useLayoutEffect, useState } from "react"
|
|
2
2
|
import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
@@ -280,8 +280,6 @@ export const SpanPreview = ({
|
|
|
280
280
|
export const WaterfallTimeline = ({
|
|
281
281
|
trace,
|
|
282
282
|
filteredSpans,
|
|
283
|
-
spanLogCounts,
|
|
284
|
-
selectedSpanLogs,
|
|
285
283
|
contentWidth,
|
|
286
284
|
bodyLines,
|
|
287
285
|
selectedSpanIndex,
|
|
@@ -291,8 +289,6 @@ export const WaterfallTimeline = ({
|
|
|
291
289
|
}: {
|
|
292
290
|
trace: TraceItem
|
|
293
291
|
filteredSpans: readonly TraceSpanItem[]
|
|
294
|
-
spanLogCounts: ReadonlyMap<string, number>
|
|
295
|
-
selectedSpanLogs: readonly LogItem[]
|
|
296
292
|
contentWidth: number
|
|
297
293
|
bodyLines: number
|
|
298
294
|
selectedSpanIndex: number | null
|
|
@@ -320,13 +316,12 @@ export const WaterfallTimeline = ({
|
|
|
320
316
|
const viewportSize = Math.max(1, bodyLines)
|
|
321
317
|
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
322
318
|
const [scrollOffset, setScrollOffset] = useState(0)
|
|
323
|
-
const lastTraceIdRef = useRef<string | null>(null)
|
|
324
319
|
|
|
325
|
-
// Reset scroll offset when the trace changes.
|
|
326
|
-
|
|
320
|
+
// Reset scroll offset when the trace changes. Keep this out of render so
|
|
321
|
+
// a trace switch doesn't force a render-phase state update on hot paths.
|
|
322
|
+
useLayoutEffect(() => {
|
|
327
323
|
setScrollOffset(0)
|
|
328
|
-
|
|
329
|
-
}
|
|
324
|
+
}, [trace.traceId])
|
|
330
325
|
|
|
331
326
|
// Auto-follow selection: only if the selected span would be hidden
|
|
332
327
|
// by the current window, shift just enough to bring it back. Runs in
|
|
@@ -328,6 +328,50 @@ describe("buildChatListRows", () => {
|
|
|
328
328
|
expect(rows[0]!.text).toBe("hello there")
|
|
329
329
|
expect(rows[1]!.text).toContain("bash")
|
|
330
330
|
})
|
|
331
|
+
|
|
332
|
+
it("uses matching tool-call context for tool-result rows", () => {
|
|
333
|
+
const chunks = buildChunks(
|
|
334
|
+
makeDetail([
|
|
335
|
+
{
|
|
336
|
+
role: "assistant",
|
|
337
|
+
content: [
|
|
338
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch" } },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
role: "tool",
|
|
343
|
+
content: [
|
|
344
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/file.ts" } },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
]),
|
|
348
|
+
)
|
|
349
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
350
|
+
expect(rows[1]!.text).toContain("bash git status --short --branch")
|
|
351
|
+
expect(rows[1]!.meta).toContain("## dev...origin/dev")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("shows read result rows with the originating file path inline", () => {
|
|
355
|
+
const chunks = buildChunks(
|
|
356
|
+
makeDetail([
|
|
357
|
+
{
|
|
358
|
+
role: "assistant",
|
|
359
|
+
content: [
|
|
360
|
+
{ type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
role: "tool",
|
|
365
|
+
content: [
|
|
366
|
+
{ type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: "1: export const x = 1" } },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
]),
|
|
370
|
+
)
|
|
371
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
372
|
+
expect(rows[1]!.text).toContain("read /src/formatter.ts @40 +80")
|
|
373
|
+
expect(rows[1]!.meta).toContain("1: export const x = 1")
|
|
374
|
+
})
|
|
331
375
|
})
|
|
332
376
|
|
|
333
377
|
describe("chunkDetailTitle + renderChunkDetailLines", () => {
|
package/src/ui/aiChatModel.ts
CHANGED
|
@@ -479,6 +479,13 @@ const firstBodyLine = (body: string) => {
|
|
|
479
479
|
return line.replace(/\s+/g, " ").trim()
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
const stripTransportGlyph = (text: string) => text.replace(/^[→←]\s+/, "")
|
|
483
|
+
|
|
484
|
+
const toolRowPreview = (text: string, width = 40) => {
|
|
485
|
+
const compact = firstBodyLine(text)
|
|
486
|
+
return compact.length > 0 ? shorten(compact, width) : null
|
|
487
|
+
}
|
|
488
|
+
|
|
482
489
|
/**
|
|
483
490
|
* Stable list rows for the main chat pane. One role divider per turn,
|
|
484
491
|
* one selectable row per chunk. Plain text chunks use their first body
|
|
@@ -487,6 +494,10 @@ const firstBodyLine = (body: string) => {
|
|
|
487
494
|
*/
|
|
488
495
|
export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
|
|
489
496
|
const rows: ChatListRow[] = []
|
|
497
|
+
const toolCallById = new Map<string, Chunk>()
|
|
498
|
+
for (const chunk of chunks) {
|
|
499
|
+
if (chunk.kind === "tool-call" && chunk.toolCallId) toolCallById.set(chunk.toolCallId, chunk)
|
|
500
|
+
}
|
|
490
501
|
let prevRole: Role | null = null
|
|
491
502
|
let prevMessageIndex = -1
|
|
492
503
|
|
|
@@ -522,7 +533,33 @@ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRo
|
|
|
522
533
|
// structured chunk headers here. Otherwise tool rows render as
|
|
523
534
|
// `→ → bash ...` and results as `← ← read ...`.
|
|
524
535
|
if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
|
|
525
|
-
text = text
|
|
536
|
+
text = stripTransportGlyph(text)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (chunk.kind === "tool-call") {
|
|
540
|
+
const preview = toolRowPreview(chunk.body)
|
|
541
|
+
// Keep row text focused on the primary action (already encoded in
|
|
542
|
+
// `header`) and use the dim right column for "there is more here"
|
|
543
|
+
// metadata only when it adds signal. For JSON-heavy tool args this
|
|
544
|
+
// is usually just noise, so we currently leave meta alone.
|
|
545
|
+
if (preview && preview !== text) {
|
|
546
|
+
meta = meta ?? null
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (chunk.kind === "tool-result") {
|
|
551
|
+
const matchingCall = chunk.toolCallId ? toolCallById.get(chunk.toolCallId) ?? null : null
|
|
552
|
+
if (matchingCall) {
|
|
553
|
+
// Carry the originating call summary into the result row so the
|
|
554
|
+
// list can answer "result of what?" without opening the modal.
|
|
555
|
+
// Example: `← bash git status --short --branch`,
|
|
556
|
+
// `← read /src/formatter.ts @40 +80`.
|
|
557
|
+
text = stripTransportGlyph(matchingCall.header)
|
|
558
|
+
}
|
|
559
|
+
const preview = toolRowPreview(chunk.body)
|
|
560
|
+
if (preview) {
|
|
561
|
+
meta = chunk.headerMeta ? `${chunk.headerMeta} · ${preview}` : preview
|
|
562
|
+
}
|
|
526
563
|
}
|
|
527
564
|
if (chunk.kind === "system") {
|
|
528
565
|
text = "prompt"
|
|
@@ -21,7 +21,7 @@ interface SharedTraceDetailsProps {
|
|
|
21
21
|
readonly traceSummary: TraceSummaryItem | null
|
|
22
22
|
readonly traceStatus: TraceDetailState["status"]
|
|
23
23
|
readonly traceError: string | null
|
|
24
|
-
readonly
|
|
24
|
+
readonly traceLogCount: number
|
|
25
25
|
readonly selectedSpanIndex: number | null
|
|
26
26
|
readonly collapsedSpanIds: ReadonlySet<string>
|
|
27
27
|
readonly waterfallFilterMode: boolean
|
|
@@ -33,7 +33,6 @@ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
|
|
|
33
33
|
readonly contentWidth: number
|
|
34
34
|
readonly bodyLines: number
|
|
35
35
|
readonly paneWidth: number
|
|
36
|
-
readonly focused: boolean
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
const TraceDetailsScene = ({
|
|
@@ -41,13 +40,12 @@ const TraceDetailsScene = ({
|
|
|
41
40
|
traceSummary,
|
|
42
41
|
traceStatus,
|
|
43
42
|
traceError,
|
|
44
|
-
|
|
43
|
+
traceLogCount,
|
|
45
44
|
contentWidth,
|
|
46
45
|
bodyLines,
|
|
47
46
|
paneWidth,
|
|
48
47
|
selectedSpanIndex,
|
|
49
48
|
collapsedSpanIds,
|
|
50
|
-
focused,
|
|
51
49
|
waterfallFilterMode,
|
|
52
50
|
waterfallFilterText,
|
|
53
51
|
onSelectSpan,
|
|
@@ -57,13 +55,12 @@ const TraceDetailsScene = ({
|
|
|
57
55
|
traceSummary={traceSummary}
|
|
58
56
|
traceStatus={traceStatus}
|
|
59
57
|
traceError={traceError}
|
|
60
|
-
|
|
58
|
+
traceLogCount={traceLogCount}
|
|
61
59
|
contentWidth={contentWidth}
|
|
62
60
|
bodyLines={bodyLines}
|
|
63
61
|
paneWidth={paneWidth}
|
|
64
62
|
selectedSpanIndex={selectedSpanIndex}
|
|
65
63
|
collapsedSpanIds={collapsedSpanIds}
|
|
66
|
-
focused={focused}
|
|
67
64
|
waterfallFilterMode={waterfallFilterMode}
|
|
68
65
|
waterfallFilterText={waterfallFilterText}
|
|
69
66
|
onSelectSpan={onSelectSpan}
|
|
@@ -292,7 +289,7 @@ export const TraceWorkspace = ({
|
|
|
292
289
|
traceSummary: selectedTraceSummary,
|
|
293
290
|
traceStatus: traceDetailState.status,
|
|
294
291
|
traceError: traceDetailState.error,
|
|
295
|
-
|
|
292
|
+
traceLogCount: logState.data.length,
|
|
296
293
|
selectedSpanIndex,
|
|
297
294
|
collapsedSpanIds,
|
|
298
295
|
waterfallFilterMode,
|
|
@@ -336,7 +333,6 @@ export const TraceWorkspace = ({
|
|
|
336
333
|
contentWidth={rightContentWidth}
|
|
337
334
|
bodyLines={wideBodyLines}
|
|
338
335
|
paneWidth={rightPaneWidth}
|
|
339
|
-
focused={false}
|
|
340
336
|
/>
|
|
341
337
|
</box>
|
|
342
338
|
</box>
|
|
@@ -352,7 +348,6 @@ export const TraceWorkspace = ({
|
|
|
352
348
|
contentWidth={leftContentWidth}
|
|
353
349
|
bodyLines={wideBodyLines}
|
|
354
350
|
paneWidth={leftPaneWidth}
|
|
355
|
-
focused={true}
|
|
356
351
|
/>
|
|
357
352
|
</box>
|
|
358
353
|
<SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
|
|
@@ -412,7 +407,6 @@ export const TraceWorkspace = ({
|
|
|
412
407
|
contentWidth={rightContentWidth}
|
|
413
408
|
bodyLines={narrowBodyLines}
|
|
414
409
|
paneWidth={contentWidth}
|
|
415
|
-
focused={false}
|
|
416
410
|
/>
|
|
417
411
|
</>
|
|
418
412
|
)
|
|
@@ -432,7 +426,6 @@ export const TraceWorkspace = ({
|
|
|
432
426
|
contentWidth={rightContentWidth}
|
|
433
427
|
bodyLines={narrowFullBodyLines}
|
|
434
428
|
paneWidth={contentWidth}
|
|
435
|
-
focused={true}
|
|
436
429
|
/>
|
|
437
430
|
) : (
|
|
438
431
|
<SpanDrillInScene
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
import { isAiSpan } from "../../domain.ts"
|
|
48
48
|
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
49
49
|
import { parseFilterText } from "../filterParser.ts"
|
|
50
|
-
import { getVisibleSpans } from "../
|
|
50
|
+
import { getVisibleSpans } from "../waterfallModel.ts"
|
|
51
51
|
|
|
52
52
|
const clampSelectionIndex = (index: number, length: number) => {
|
|
53
53
|
if (length === 0) return 0
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { filterFacets } from "./AttrFilterModal.tsx"
|
|
|
39
39
|
import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
|
|
40
40
|
import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
41
41
|
import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
|
|
42
|
-
import { getVisibleSpans } from "./
|
|
42
|
+
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
43
43
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
44
44
|
|
|
45
45
|
/**
|