@kitlangton/motel 0.2.0 → 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/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -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 +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
package/src/daemon.ts
CHANGED
|
@@ -2,17 +2,27 @@ 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 { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry
|
|
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
|
|
14
|
+
/** Fast probe used inside the waitForHealthy poll loop — we call it
|
|
15
|
+
* every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
|
|
16
|
+
const HEALTH_FAST_TIMEOUT_MS = 750
|
|
17
|
+
/** Patient probe used on critical paths: the first getStatus() call
|
|
18
|
+
* in ensure(), and the final pre-throw check after a spawned child
|
|
19
|
+
* dies. A real daemon with a busy SQLite writer (FTS backfill, big
|
|
20
|
+
* DB) can easily take 1-2s to answer /api/health — if we declare
|
|
21
|
+
* the port empty at 750ms we'll spawn a duplicate and collide with
|
|
22
|
+
* EADDRINUSE. 3s is long enough to tolerate a slow healthy daemon
|
|
23
|
+
* and short enough that a truly-down daemon is still detected
|
|
24
|
+
* before START_TIMEOUT_MS fires. */
|
|
25
|
+
const HEALTH_PATIENT_TIMEOUT_MS = 3_000
|
|
16
26
|
|
|
17
27
|
type HealthShape = {
|
|
18
28
|
readonly ok: boolean
|
|
@@ -32,6 +42,8 @@ type LockShape = {
|
|
|
32
42
|
|
|
33
43
|
type DaemonConfig = {
|
|
34
44
|
readonly repoRoot: string
|
|
45
|
+
readonly serverEntry: string
|
|
46
|
+
readonly workdir: string
|
|
35
47
|
readonly runtimeDir: string
|
|
36
48
|
readonly databasePath: string
|
|
37
49
|
readonly logPath: string
|
|
@@ -67,6 +79,7 @@ export type DaemonManager = {
|
|
|
67
79
|
|
|
68
80
|
type DaemonOptions = {
|
|
69
81
|
readonly repoRoot?: string
|
|
82
|
+
readonly workdir?: string
|
|
70
83
|
readonly runtimeDir?: string
|
|
71
84
|
readonly databasePath?: string
|
|
72
85
|
readonly host?: string
|
|
@@ -84,12 +97,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
84
97
|
|
|
85
98
|
const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
86
99
|
const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
|
|
87
|
-
const
|
|
100
|
+
const workdir = path.resolve(options.workdir ?? process.cwd())
|
|
101
|
+
const runtimeDir = path.resolve(options.runtimeDir ?? path.join(workdir, ".motel-data"))
|
|
88
102
|
const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
|
|
89
103
|
const host = options.host ?? DEFAULT_HOST
|
|
90
104
|
const port = options.port ?? DEFAULT_PORT
|
|
91
105
|
return {
|
|
92
106
|
repoRoot,
|
|
107
|
+
serverEntry: path.join(repoRoot, "src/server.ts"),
|
|
108
|
+
workdir,
|
|
93
109
|
runtimeDir,
|
|
94
110
|
databasePath,
|
|
95
111
|
logPath: path.join(runtimeDir, "daemon.log"),
|
|
@@ -100,16 +116,14 @@ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
118
|
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
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)
|
|
108
123
|
}
|
|
109
124
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
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}`
|
|
113
127
|
return entries
|
|
114
128
|
.filter((entry) => {
|
|
115
129
|
const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
|
|
@@ -118,8 +132,6 @@ const pickByCwd = (entries: readonly RegistryEntry[]) => {
|
|
|
118
132
|
.sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
const readRegistryEntry = () => pickByCwd(listAliveEntries())
|
|
122
|
-
|
|
123
135
|
const expectedEnv = (config: DaemonConfig) => ({
|
|
124
136
|
MOTEL_OTEL_BASE_URL: config.baseUrl,
|
|
125
137
|
MOTEL_OTEL_QUERY_URL: config.baseUrl,
|
|
@@ -133,10 +145,11 @@ const expectedEnv = (config: DaemonConfig) => ({
|
|
|
133
145
|
export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
|
|
134
146
|
const config = resolveConfig(options)
|
|
135
147
|
const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
|
|
148
|
+
const readRegistryEntry = () => pickByWorkdir(listAliveEntries(), config.workdir)
|
|
136
149
|
|
|
137
|
-
const fetchHealth = async (): Promise<HealthShape | null> => {
|
|
150
|
+
const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
|
|
138
151
|
try {
|
|
139
|
-
const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(
|
|
152
|
+
const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
140
153
|
if (!response.ok) return null
|
|
141
154
|
return await response.json() as HealthShape
|
|
142
155
|
} catch {
|
|
@@ -144,12 +157,39 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
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
|
+
|
|
147
187
|
const describeManagedMismatch = (health: HealthShape) => {
|
|
148
188
|
if (health.service !== MOTEL_SERVICE_ID) {
|
|
149
189
|
return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
|
|
150
190
|
}
|
|
151
|
-
if (!
|
|
152
|
-
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}.`
|
|
153
193
|
}
|
|
154
194
|
if (health.databasePath !== config.databasePath) {
|
|
155
195
|
return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
|
|
@@ -157,6 +197,62 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
157
197
|
return null
|
|
158
198
|
}
|
|
159
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Mismatch check against a registry entry — mirrors describeManagedMismatch
|
|
202
|
+
* but drives off the registry file instead of an HTTP health response.
|
|
203
|
+
* Used on the fast path in getStatus so warm-start doesn't need to wait
|
|
204
|
+
* on an HTTP round-trip that may queue behind heavy OTLP ingest.
|
|
205
|
+
*
|
|
206
|
+
* The service-id check is implicit: any entry living in the motel
|
|
207
|
+
* registry dir is by construction a motel daemon. databasePath is
|
|
208
|
+
* optional for back-compat with entries written by older builds;
|
|
209
|
+
* when absent we skip the DB check rather than refusing to adopt.
|
|
210
|
+
*/
|
|
211
|
+
const describeRegistryMismatch = (entry: RegistryEntry): string | null => {
|
|
212
|
+
if (!workdirMatches(config.workdir, entry.workdir)) {
|
|
213
|
+
return `Port ${config.port} is serving motel for ${entry.workdir}, not ${config.workdir}.`
|
|
214
|
+
}
|
|
215
|
+
if (entry.databasePath && entry.databasePath !== config.databasePath) {
|
|
216
|
+
return `Port ${config.port} is serving motel with ${entry.databasePath}, expected ${config.databasePath}.`
|
|
217
|
+
}
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build a DaemonStatus from a live registry entry. Returns null when
|
|
223
|
+
* there's no entry for our cwd, the registered pid isn't running, or
|
|
224
|
+
* the entry is for a differently-configured daemon (different port).
|
|
225
|
+
* This is the fast path: no HTTP, no event-loop round-trip, just a
|
|
226
|
+
* directory read and a process.kill(pid, 0) liveness probe.
|
|
227
|
+
*/
|
|
228
|
+
const getStatusFromRegistry = (): DaemonStatus | null => {
|
|
229
|
+
const entry = readRegistryEntry()
|
|
230
|
+
if (!entry) return null
|
|
231
|
+
// Port discriminator: a motel registry shared across several
|
|
232
|
+
// daemons (e.g., user running two instances on different
|
|
233
|
+
// ports from the same workdir, or a test harness on a random
|
|
234
|
+
// port) would otherwise have us adopt an unrelated daemon.
|
|
235
|
+
// URL match is a fast, unambiguous identity check.
|
|
236
|
+
if (entry.url !== config.baseUrl) return null
|
|
237
|
+
const mismatch = describeRegistryMismatch(entry)
|
|
238
|
+
return {
|
|
239
|
+
running: mismatch === null,
|
|
240
|
+
managed: mismatch === null,
|
|
241
|
+
service: MOTEL_SERVICE_ID,
|
|
242
|
+
pid: entry.pid,
|
|
243
|
+
url: entry.url,
|
|
244
|
+
databasePath: entry.databasePath ?? config.databasePath,
|
|
245
|
+
workdir: entry.workdir,
|
|
246
|
+
startedAt: entry.startedAt,
|
|
247
|
+
version: entry.version,
|
|
248
|
+
sameWorkdir: workdirMatches(config.workdir, entry.workdir),
|
|
249
|
+
reason: mismatch,
|
|
250
|
+
logPath: config.logPath,
|
|
251
|
+
lockPath: config.lockPath,
|
|
252
|
+
registryPid: entry.pid,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
160
256
|
const readLock = async (): Promise<LockShape | null> => {
|
|
161
257
|
try {
|
|
162
258
|
const raw = await fsp.readFile(config.lockPath, "utf8")
|
|
@@ -208,7 +304,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
208
304
|
return fs.openSync(config.logPath, "a")
|
|
209
305
|
}
|
|
210
306
|
|
|
211
|
-
const waitForHealthy = async (pid: number) => {
|
|
307
|
+
const waitForHealthy = async (pid: number, logOffset: number) => {
|
|
212
308
|
const deadline = Date.now() + START_TIMEOUT_MS
|
|
213
309
|
while (Date.now() < deadline) {
|
|
214
310
|
const health = await fetchHealth()
|
|
@@ -217,7 +313,20 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
217
313
|
if (!mismatch) return health
|
|
218
314
|
throw new Error(mismatch)
|
|
219
315
|
}
|
|
316
|
+
const started = await detectStartedFromLog(pid, logOffset)
|
|
317
|
+
if (started) return started
|
|
220
318
|
if (!isAlive(pid)) {
|
|
319
|
+
// The spawned child is gone. Before declaring failure,
|
|
320
|
+
// do one patient probe: the child may have died from
|
|
321
|
+
// EADDRINUSE because another healthy motel is alive on
|
|
322
|
+
// the port but was answering /api/health too slowly for
|
|
323
|
+
// our fast poll. If that's the case, adopt it.
|
|
324
|
+
const patient = await fetchHealth(HEALTH_PATIENT_TIMEOUT_MS)
|
|
325
|
+
if (patient) {
|
|
326
|
+
const mismatch = describeManagedMismatch(patient)
|
|
327
|
+
if (!mismatch) return patient
|
|
328
|
+
throw new Error(mismatch)
|
|
329
|
+
}
|
|
221
330
|
throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
|
|
222
331
|
}
|
|
223
332
|
await sleep(POLL_INTERVAL_MS)
|
|
@@ -237,16 +346,33 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
237
346
|
while (Date.now() < deadline) {
|
|
238
347
|
if (!isAlive(pid)) return
|
|
239
348
|
const health = await fetchHealth()
|
|
240
|
-
if (
|
|
349
|
+
if (health && health.pid !== pid) return
|
|
350
|
+
const registry = readRegistryEntry()
|
|
351
|
+
if (!health && (!registry || registry.pid !== pid)) return
|
|
241
352
|
await sleep(POLL_INTERVAL_MS)
|
|
242
353
|
}
|
|
243
354
|
|
|
244
355
|
throw new Error(`Timed out waiting for daemon ${pid} to stop.`)
|
|
245
356
|
}
|
|
246
357
|
|
|
247
|
-
const getStatus = async (): Promise<DaemonStatus> => {
|
|
358
|
+
const getStatus = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<DaemonStatus> => {
|
|
359
|
+
// Fast path: trust the local filesystem registry. When a motel
|
|
360
|
+
// daemon started on this machine it wrote an entry for its pid
|
|
361
|
+
// + cwd + databasePath; if that entry is still there and the pid
|
|
362
|
+
// is alive, the daemon is almost certainly the one we want to
|
|
363
|
+
// adopt. HTTP health is skipped because the daemon's health
|
|
364
|
+
// endpoint can queue behind heavy OTLP ingest traffic, making
|
|
365
|
+
// the probe unreliable exactly when the daemon is busy.
|
|
366
|
+
const registryStatus = getStatusFromRegistry()
|
|
367
|
+
if (registryStatus) return registryStatus
|
|
368
|
+
|
|
369
|
+
// No local evidence → fall back to HTTP. Covers the edge cases
|
|
370
|
+
// where: a motel daemon is running but was started before this
|
|
371
|
+
// registry-first path shipped; OR the port is held by something
|
|
372
|
+
// entirely unrelated (the mismatch check turns that into a
|
|
373
|
+
// human-readable reason).
|
|
248
374
|
const registry = readRegistryEntry()
|
|
249
|
-
const health = await fetchHealth()
|
|
375
|
+
const health = await fetchHealth(timeoutMs)
|
|
250
376
|
if (!health) {
|
|
251
377
|
return {
|
|
252
378
|
running: false,
|
|
@@ -258,7 +384,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
258
384
|
workdir: registry?.workdir ?? null,
|
|
259
385
|
startedAt: registry?.startedAt ?? null,
|
|
260
386
|
version: registry?.version ?? null,
|
|
261
|
-
sameWorkdir: registry ?
|
|
387
|
+
sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
|
|
262
388
|
reason: registry ? "Registry entry exists but daemon is not healthy." : null,
|
|
263
389
|
logPath: config.logPath,
|
|
264
390
|
lockPath: config.lockPath,
|
|
@@ -277,7 +403,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
277
403
|
workdir: health.workdir,
|
|
278
404
|
startedAt: health.startedAt,
|
|
279
405
|
version: health.version,
|
|
280
|
-
sameWorkdir:
|
|
406
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
281
407
|
reason: mismatch,
|
|
282
408
|
logPath: config.logPath,
|
|
283
409
|
lockPath: config.lockPath,
|
|
@@ -286,7 +412,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
286
412
|
}
|
|
287
413
|
|
|
288
414
|
const ensure = async (): Promise<DaemonStatus> => {
|
|
289
|
-
|
|
415
|
+
// Use the patient timeout for the initial probe — this is the
|
|
416
|
+
// critical "is there already a daemon here?" check. A false
|
|
417
|
+
// negative here drops us into the spawn path and collides with
|
|
418
|
+
// any slow-but-healthy daemon sitting on the port.
|
|
419
|
+
const existing = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
|
|
290
420
|
if (existing.managed && existing.running) return existing
|
|
291
421
|
if (existing.service !== null && existing.reason) {
|
|
292
422
|
throw new Error(existing.reason)
|
|
@@ -295,17 +425,22 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
295
425
|
const lock = await acquireStartupLock()
|
|
296
426
|
let spawnedPid: number | null = null
|
|
297
427
|
try {
|
|
298
|
-
|
|
428
|
+
// Same reasoning for the post-lock re-check: another ensure()
|
|
429
|
+
// may have spawned a daemon between our first probe and the
|
|
430
|
+
// lock grant, and its initial health response can be slow
|
|
431
|
+
// while the runtime warms up.
|
|
432
|
+
const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
|
|
299
433
|
if (rechecked.managed && rechecked.running) return rechecked
|
|
300
434
|
if (rechecked.service !== null && rechecked.reason) {
|
|
301
435
|
throw new Error(rechecked.reason)
|
|
302
436
|
}
|
|
303
437
|
|
|
304
438
|
const logFd = await openLogFile()
|
|
439
|
+
const logOffset = fs.fstatSync(logFd).size
|
|
305
440
|
try {
|
|
306
441
|
const proc = Bun.spawn({
|
|
307
|
-
cmd: [process.execPath, "run",
|
|
308
|
-
cwd: config.
|
|
442
|
+
cmd: [process.execPath, "run", config.serverEntry],
|
|
443
|
+
cwd: config.workdir,
|
|
309
444
|
detached: true,
|
|
310
445
|
env: {
|
|
311
446
|
...process.env,
|
|
@@ -323,7 +458,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
323
458
|
throw new Error("Daemon failed to spawn.")
|
|
324
459
|
}
|
|
325
460
|
|
|
326
|
-
const health = await waitForHealthy(spawnedPid)
|
|
461
|
+
const health = await waitForHealthy(spawnedPid, logOffset)
|
|
327
462
|
return {
|
|
328
463
|
running: true,
|
|
329
464
|
managed: true,
|
|
@@ -334,7 +469,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
334
469
|
workdir: health.workdir,
|
|
335
470
|
startedAt: health.startedAt,
|
|
336
471
|
version: health.version,
|
|
337
|
-
sameWorkdir:
|
|
472
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
338
473
|
reason: null,
|
|
339
474
|
logPath: config.logPath,
|
|
340
475
|
lockPath: config.lockPath,
|
|
@@ -371,7 +506,10 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
371
506
|
}),
|
|
372
507
|
getStatus: Effect.fn("DaemonManager.getStatus")(() =>
|
|
373
508
|
Effect.tryPromise({
|
|
374
|
-
|
|
509
|
+
// Wrapped so Effect.tryPromise only sees the no-arg call
|
|
510
|
+
// signature — the optional timeoutMs parameter is an
|
|
511
|
+
// internal detail used by ensure()'s critical probes.
|
|
512
|
+
try: () => getStatus(),
|
|
375
513
|
catch: mapError,
|
|
376
514
|
}),
|
|
377
515
|
)(),
|
|
@@ -390,9 +528,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
390
528
|
}
|
|
391
529
|
}
|
|
392
530
|
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
export const
|
|
396
|
-
export const
|
|
397
|
-
export const ensureManagedDaemon = defaultManager.ensure
|
|
398
|
-
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)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { AI_FTS_KEYS, isAiSpan } from "./domain.ts"
|
|
3
|
+
|
|
4
|
+
describe("isAiSpan", () => {
|
|
5
|
+
test("returns false for empty tags", () => {
|
|
6
|
+
expect(isAiSpan({})).toBe(false)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("returns false when no AI key is present", () => {
|
|
10
|
+
expect(isAiSpan({
|
|
11
|
+
"service.name": "web",
|
|
12
|
+
"http.method": "GET",
|
|
13
|
+
"db.statement": "SELECT 1",
|
|
14
|
+
})).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("detects Vercel AI SDK keys", () => {
|
|
18
|
+
expect(isAiSpan({ "ai.prompt.messages": "[]" })).toBe(true)
|
|
19
|
+
expect(isAiSpan({ "ai.response.text": "hi" })).toBe(true)
|
|
20
|
+
expect(isAiSpan({ "ai.toolCall.args": "{}" })).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("detects OpenTelemetry gen_ai semconv keys", () => {
|
|
24
|
+
expect(isAiSpan({ "gen_ai.prompt": "foo" })).toBe(true)
|
|
25
|
+
expect(isAiSpan({ "gen_ai.input.messages": "[]" })).toBe(true)
|
|
26
|
+
expect(isAiSpan({ "gen_ai.tool.definitions": "[]" })).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("detects OpenInference keys", () => {
|
|
30
|
+
expect(isAiSpan({ "input.value": "hi" })).toBe(true)
|
|
31
|
+
expect(isAiSpan({ "output.value": "hi" })).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("detects a single AI key among many non-AI keys", () => {
|
|
35
|
+
expect(isAiSpan({
|
|
36
|
+
"service.name": "web",
|
|
37
|
+
"http.method": "POST",
|
|
38
|
+
"http.status_code": "200",
|
|
39
|
+
"ai.model.id": "ignored-not-in-fts-keys",
|
|
40
|
+
"ai.prompt": "tell me a joke",
|
|
41
|
+
})).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("ignores AI-adjacent keys that are not in the FTS set", () => {
|
|
45
|
+
// `ai.model.provider`, `ai.settings.*`, `ai.telemetry.*` carry
|
|
46
|
+
// metadata, not content, so they intentionally aren't part of
|
|
47
|
+
// AI_FTS_KEYS. A span with ONLY those should not be flagged.
|
|
48
|
+
expect(isAiSpan({
|
|
49
|
+
"ai.model.provider": "openai",
|
|
50
|
+
"ai.model.id": "gpt-4",
|
|
51
|
+
"ai.settings.maxRetries": "2",
|
|
52
|
+
})).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("every documented key triggers detection", () => {
|
|
56
|
+
// Guard against a future reshuffle of AI_FTS_KEYS that might
|
|
57
|
+
// drop a key silently — every declared key should round-trip.
|
|
58
|
+
for (const key of AI_FTS_KEYS) {
|
|
59
|
+
expect(isAiSpan({ [key]: "payload" })).toBe(true)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/domain.ts
CHANGED
|
@@ -195,6 +195,22 @@ export const AI_FTS_KEYS = [
|
|
|
195
195
|
*/
|
|
196
196
|
export const AI_TEXT_SEARCH_KEYS = AI_FTS_KEYS
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* True if a span's tags contain any of the AI content keys we track.
|
|
200
|
+
* Used as the single source of truth for "this span has LLM payloads
|
|
201
|
+
* worth a specialized view" — drives the ✦ marker in the waterfall row
|
|
202
|
+
* and picks the chat-flavored renderer when the user drills into the
|
|
203
|
+
* span's detail. Scanning happens once per row during render so this
|
|
204
|
+
* needs to stay O(AI_FTS_KEYS.length) with cheap `in` checks rather
|
|
205
|
+
* than an `Object.keys(...).some(...)` allocation.
|
|
206
|
+
*/
|
|
207
|
+
export const isAiSpan = (tags: Readonly<Record<string, string>>): boolean => {
|
|
208
|
+
for (const key of AI_FTS_KEYS) {
|
|
209
|
+
if (key in tags) return true
|
|
210
|
+
}
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
|
|
198
214
|
const PREVIEW_LENGTH = 200
|
|
199
215
|
|
|
200
216
|
export const truncatePreview = (value: string | null | undefined): string | null => {
|