@aryasaatvik/motel 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import { promises as fsp } from "node:fs"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { isAlive, isManagedDaemonProcess, listAliveEntries, motelStateDir, MOTEL_SERVICE_ID, processIdentity, removeRegistryEntry, type RegistryEntry } from "./registry.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
|
|
8
|
+
const DEFAULT_HOST = "127.0.0.1"
|
|
9
|
+
const DEFAULT_PORT = 27686
|
|
10
|
+
const START_TIMEOUT_MS = 30_000
|
|
11
|
+
const STOP_TIMEOUT_MS = 10_000
|
|
12
|
+
const LOCK_TIMEOUT_MS = 10_000
|
|
13
|
+
const START_POLL_INTERVAL_MS = 25
|
|
14
|
+
const POLL_INTERVAL_MS = 150
|
|
15
|
+
/** Fast probe used inside the waitForHealthy poll loop — we call it
|
|
16
|
+
* every POLL_INTERVAL_MS, so a generous budget would stall the loop. */
|
|
17
|
+
const HEALTH_FAST_TIMEOUT_MS = 750
|
|
18
|
+
/** Patient probe used on critical paths: the first getStatus() call
|
|
19
|
+
* in ensure(), and the final pre-throw check after a spawned child
|
|
20
|
+
* dies. A real daemon with a busy SQLite writer (FTS backfill, big
|
|
21
|
+
* DB) can easily take 1-2s to answer /api/health — if we declare
|
|
22
|
+
* the port empty at 750ms we'll spawn a duplicate and collide with
|
|
23
|
+
* EADDRINUSE. 3s is long enough to tolerate a slow healthy daemon
|
|
24
|
+
* and short enough that a truly-down daemon is still detected
|
|
25
|
+
* before START_TIMEOUT_MS fires. */
|
|
26
|
+
const HEALTH_PATIENT_TIMEOUT_MS = 3_000
|
|
27
|
+
const INGEST_PROBE_TIMEOUT_MS = 3_000
|
|
28
|
+
|
|
29
|
+
type HealthShape = {
|
|
30
|
+
readonly ok: boolean
|
|
31
|
+
readonly service: string
|
|
32
|
+
readonly databasePath: string
|
|
33
|
+
readonly pid: number
|
|
34
|
+
readonly url: string
|
|
35
|
+
readonly workdir: string
|
|
36
|
+
readonly startedAt: string
|
|
37
|
+
readonly version: string
|
|
38
|
+
readonly instanceId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type LockShape = {
|
|
42
|
+
readonly pid: number
|
|
43
|
+
readonly createdAt: string
|
|
44
|
+
readonly processIdentity?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type DaemonConfig = {
|
|
48
|
+
readonly repoRoot: string
|
|
49
|
+
readonly serverEntry: string
|
|
50
|
+
readonly workdir: string
|
|
51
|
+
readonly runtimeDir: string
|
|
52
|
+
readonly databasePath: string
|
|
53
|
+
readonly logPath: string
|
|
54
|
+
readonly lockPath: string
|
|
55
|
+
readonly host: string
|
|
56
|
+
readonly port: number
|
|
57
|
+
readonly baseUrl: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type DaemonStatus = {
|
|
61
|
+
readonly running: boolean
|
|
62
|
+
readonly managed: boolean
|
|
63
|
+
readonly service: string | null
|
|
64
|
+
readonly pid: number | null
|
|
65
|
+
readonly url: string
|
|
66
|
+
readonly databasePath: string
|
|
67
|
+
readonly workdir: string | null
|
|
68
|
+
readonly startedAt: string | null
|
|
69
|
+
readonly version: string | null
|
|
70
|
+
readonly sameWorkdir: boolean
|
|
71
|
+
readonly reason: string | null
|
|
72
|
+
readonly logPath: string
|
|
73
|
+
readonly lockPath: string
|
|
74
|
+
readonly registryPid: number | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type DaemonManager = {
|
|
78
|
+
readonly applyEnv: Effect.Effect<void>
|
|
79
|
+
readonly getStatus: Effect.Effect<DaemonStatus, DaemonError>
|
|
80
|
+
readonly ensure: Effect.Effect<DaemonStatus, DaemonError>
|
|
81
|
+
readonly stop: Effect.Effect<DaemonStatus, DaemonError>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type DaemonOptions = {
|
|
85
|
+
readonly repoRoot?: string
|
|
86
|
+
readonly workdir?: string
|
|
87
|
+
readonly runtimeDir?: string
|
|
88
|
+
readonly databasePath?: string
|
|
89
|
+
readonly host?: string
|
|
90
|
+
readonly port?: number
|
|
91
|
+
readonly startTimeoutMs?: number
|
|
92
|
+
readonly gracefulStopTimeoutMs?: number
|
|
93
|
+
readonly forceStopTimeoutMs?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DaemonError extends Error {
|
|
97
|
+
readonly _tag = "DaemonError"
|
|
98
|
+
constructor(message: string) {
|
|
99
|
+
super(message)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
104
|
+
|
|
105
|
+
const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
|
|
106
|
+
const envBaseUrl = new URL(process.env.MOTEL_OTEL_BASE_URL?.trim() || process.env.MOTEL_OTEL_QUERY_URL?.trim() || `http://${DEFAULT_HOST}:${DEFAULT_PORT}`)
|
|
107
|
+
const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
|
|
108
|
+
const workdir = path.resolve(options.workdir ?? process.cwd())
|
|
109
|
+
const runtimeDir = path.resolve(options.runtimeDir ?? motelStateDir())
|
|
110
|
+
const databasePath = path.resolve(options.databasePath ?? process.env.MOTEL_OTEL_DB_PATH?.trim() ?? path.join(runtimeDir, "telemetry.sqlite"))
|
|
111
|
+
const host = options.host ?? process.env.MOTEL_OTEL_HOST?.trim() ?? envBaseUrl.hostname
|
|
112
|
+
const envPort = Number.parseInt(process.env.MOTEL_OTEL_PORT?.trim() || envBaseUrl.port, 10)
|
|
113
|
+
const port = options.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT)
|
|
114
|
+
return {
|
|
115
|
+
repoRoot,
|
|
116
|
+
serverEntry: path.join(repoRoot, "src/server.ts"),
|
|
117
|
+
workdir,
|
|
118
|
+
runtimeDir,
|
|
119
|
+
databasePath,
|
|
120
|
+
logPath: path.join(runtimeDir, "daemon.log"),
|
|
121
|
+
lockPath: path.join(runtimeDir, "daemon.lock"),
|
|
122
|
+
host,
|
|
123
|
+
port,
|
|
124
|
+
baseUrl: `http://${host}:${port}`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
|
|
129
|
+
const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
|
|
130
|
+
const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
|
|
131
|
+
return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pickByUrl = (entries: readonly RegistryEntry[], baseUrl: string, databasePath: string) => {
|
|
135
|
+
return entries
|
|
136
|
+
.filter((entry) => {
|
|
137
|
+
return entry.url === baseUrl && (entry.databasePath === undefined || entry.databasePath === databasePath)
|
|
138
|
+
})
|
|
139
|
+
.sort((a, b) => Number(b.databasePath === databasePath) - Number(a.databasePath === databasePath))[0] ?? null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const expectedEnv = (config: DaemonConfig, instanceId?: string) => ({
|
|
143
|
+
MOTEL_RUNTIME_DIR: config.runtimeDir,
|
|
144
|
+
...(instanceId ? { MOTEL_DAEMON_INSTANCE_ID: instanceId } : {}),
|
|
145
|
+
MOTEL_OTEL_BASE_URL: config.baseUrl,
|
|
146
|
+
MOTEL_OTEL_QUERY_URL: config.baseUrl,
|
|
147
|
+
MOTEL_OTEL_HOST: config.host,
|
|
148
|
+
MOTEL_OTEL_PORT: String(config.port),
|
|
149
|
+
MOTEL_OTEL_DB_PATH: config.databasePath,
|
|
150
|
+
MOTEL_OTEL_EXPORTER_URL: `${config.baseUrl}/v1/traces`,
|
|
151
|
+
MOTEL_OTEL_LOGS_EXPORTER_URL: `${config.baseUrl}/v1/logs`,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
|
|
155
|
+
const config = resolveConfig(options)
|
|
156
|
+
const startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS
|
|
157
|
+
const gracefulStopTimeoutMs = options.gracefulStopTimeoutMs ?? STOP_TIMEOUT_MS
|
|
158
|
+
const forceStopTimeoutMs = options.forceStopTimeoutMs ?? 2_000
|
|
159
|
+
const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
|
|
160
|
+
const readRegistryEntry = () => pickByUrl(listAliveEntries(config.runtimeDir), config.baseUrl, config.databasePath)
|
|
161
|
+
|
|
162
|
+
const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(`${config.baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
165
|
+
if (!response.ok) return null
|
|
166
|
+
const health = await response.json() as HealthShape
|
|
167
|
+
return health.ok ? health : null
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const fetchIngestProbe = async () => {
|
|
174
|
+
try {
|
|
175
|
+
const postEmpty = (path: string) => fetch(`${config.baseUrl}${path}`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json" },
|
|
178
|
+
body: "{}",
|
|
179
|
+
signal: AbortSignal.timeout(INGEST_PROBE_TIMEOUT_MS),
|
|
180
|
+
})
|
|
181
|
+
const [traces, logs] = await Promise.all([postEmpty("/v1/traces"), postEmpty("/v1/logs")])
|
|
182
|
+
return traces.ok && logs.ok
|
|
183
|
+
} catch {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const describeManagedMismatch = (health: HealthShape) => {
|
|
189
|
+
if (health.service !== MOTEL_SERVICE_ID) {
|
|
190
|
+
return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
|
|
191
|
+
}
|
|
192
|
+
if (health.databasePath !== config.databasePath) {
|
|
193
|
+
return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const readLock = async (): Promise<LockShape | null> => {
|
|
199
|
+
try {
|
|
200
|
+
const raw = await fsp.readFile(config.lockPath, "utf8")
|
|
201
|
+
return JSON.parse(raw) as LockShape
|
|
202
|
+
} catch {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const removeStaleLock = async () => {
|
|
208
|
+
const current = await readLock()
|
|
209
|
+
if (!current) {
|
|
210
|
+
await fsp.rm(config.lockPath, { force: true })
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
|
|
214
|
+
await fsp.rm(config.lockPath, { force: true })
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const acquireStartupLock = async () => {
|
|
219
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
220
|
+
await fsp.mkdir(config.runtimeDir, { recursive: true })
|
|
221
|
+
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
try {
|
|
224
|
+
const handle = await fsp.open(config.lockPath, "wx")
|
|
225
|
+
const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), processIdentity: processIdentity(process.pid) ?? undefined } satisfies LockShape)
|
|
226
|
+
await handle.writeFile(contents, "utf8")
|
|
227
|
+
return {
|
|
228
|
+
release: async () => {
|
|
229
|
+
await handle.close().catch(() => undefined)
|
|
230
|
+
await fsp.rm(config.lockPath, { force: true }).catch(() => undefined)
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const errno = error as NodeJS.ErrnoException
|
|
235
|
+
if (errno.code !== "EEXIST") throw error
|
|
236
|
+
if (await removeStaleLock()) continue
|
|
237
|
+
await sleep(POLL_INTERVAL_MS)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const openLogFile = async () => {
|
|
245
|
+
await fsp.mkdir(config.runtimeDir, { recursive: true })
|
|
246
|
+
return fs.openSync(config.logPath, "a")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const waitForHealthy = async (pid: number, instanceId: string) => {
|
|
250
|
+
const deadline = Date.now() + startTimeoutMs
|
|
251
|
+
while (Date.now() < deadline) {
|
|
252
|
+
const health = await fetchHealth()
|
|
253
|
+
if (health) {
|
|
254
|
+
const mismatch = describeManagedMismatch(health)
|
|
255
|
+
const registry = readRegistryEntry()
|
|
256
|
+
if (!mismatch && health.pid === pid && health.instanceId === instanceId && registry?.pid === pid && registry.instanceId === instanceId && isManagedDaemonProcess(registry) && await fetchIngestProbe()) return health
|
|
257
|
+
if (mismatch) throw new Error(mismatch)
|
|
258
|
+
}
|
|
259
|
+
if (!isAlive(pid)) {
|
|
260
|
+
throw new Error(`Daemon process ${pid} exited before becoming healthy. See ${config.logPath}.`)
|
|
261
|
+
}
|
|
262
|
+
await sleep(START_POLL_INTERVAL_MS)
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`Timed out waiting for daemon health at ${config.baseUrl}/api/health. See ${config.logPath}.`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const waitUntilNotOwned = async (entry: RegistryEntry, timeoutMs: number) => {
|
|
268
|
+
const deadline = Date.now() + timeoutMs
|
|
269
|
+
while (Date.now() < deadline) {
|
|
270
|
+
if (!isManagedDaemonProcess(entry)) return true
|
|
271
|
+
await sleep(POLL_INTERVAL_MS)
|
|
272
|
+
}
|
|
273
|
+
return !isManagedDaemonProcess(entry)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const stopPid = async (entry: RegistryEntry) => {
|
|
277
|
+
if (!isManagedDaemonProcess(entry)) {
|
|
278
|
+
throw new Error(`Refusing to stop pid ${entry.pid}: registry identity does not match the running managed daemon.`)
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
process.kill(entry.pid, "SIGTERM")
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const errno = error as NodeJS.ErrnoException
|
|
284
|
+
if (errno.code !== "ESRCH") throw error
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!await waitUntilNotOwned(entry, gracefulStopTimeoutMs)) {
|
|
288
|
+
try {
|
|
289
|
+
process.kill(entry.pid, "SIGKILL")
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error
|
|
292
|
+
}
|
|
293
|
+
if (!await waitUntilNotOwned(entry, forceStopTimeoutMs)) {
|
|
294
|
+
throw new Error(`Timed out force-killing daemon ${entry.pid}.`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const current = readRegistryEntry()
|
|
298
|
+
if (current?.pid === entry.pid && current.instanceId === entry.instanceId) removeRegistryEntry(entry.pid, config.runtimeDir)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const getStatus = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<DaemonStatus> => {
|
|
302
|
+
const registry = readRegistryEntry()
|
|
303
|
+
const health = await fetchHealth(timeoutMs)
|
|
304
|
+
if (!health) {
|
|
305
|
+
return {
|
|
306
|
+
running: false,
|
|
307
|
+
managed: false,
|
|
308
|
+
service: null,
|
|
309
|
+
pid: registry?.pid ?? null,
|
|
310
|
+
url: config.baseUrl,
|
|
311
|
+
databasePath: config.databasePath,
|
|
312
|
+
workdir: registry?.workdir ?? null,
|
|
313
|
+
startedAt: registry?.startedAt ?? null,
|
|
314
|
+
version: registry?.version ?? null,
|
|
315
|
+
sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
|
|
316
|
+
reason: registry ? "Registry entry exists but daemon is not healthy." : null,
|
|
317
|
+
logPath: config.logPath,
|
|
318
|
+
lockPath: config.lockPath,
|
|
319
|
+
registryPid: registry?.pid ?? null,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const mismatch = describeManagedMismatch(health)
|
|
324
|
+
const managed = mismatch === null && registry?.pid === health.pid && registry.instanceId === health.instanceId && isManagedDaemonProcess(registry)
|
|
325
|
+
return {
|
|
326
|
+
running: mismatch === null,
|
|
327
|
+
managed,
|
|
328
|
+
service: health.service,
|
|
329
|
+
pid: health.pid,
|
|
330
|
+
url: health.url,
|
|
331
|
+
databasePath: health.databasePath,
|
|
332
|
+
workdir: health.workdir,
|
|
333
|
+
startedAt: health.startedAt,
|
|
334
|
+
version: health.version,
|
|
335
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
336
|
+
reason: mismatch ?? (managed ? null : "Responsive motel server is not an identity-verified managed daemon."),
|
|
337
|
+
logPath: config.logPath,
|
|
338
|
+
lockPath: config.lockPath,
|
|
339
|
+
registryPid: registry?.pid ?? null,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ensure = async (): Promise<DaemonStatus> => {
|
|
344
|
+
// Use the patient timeout for the initial probe — this is the
|
|
345
|
+
// critical "is there already a daemon here?" check. A false
|
|
346
|
+
// negative here drops us into the spawn path and collides with
|
|
347
|
+
// any slow-but-healthy daemon sitting on the port.
|
|
348
|
+
const existing = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
|
|
349
|
+
const existingEntry = readRegistryEntry()
|
|
350
|
+
if (existing.managed && existing.running) {
|
|
351
|
+
// /api/health can stay healthy after the lazy ingest worker/RPC path
|
|
352
|
+
// has been poisoned by an interrupted request. Empty OTLP posts are
|
|
353
|
+
// side-effect free and exercise the same path real exporters need.
|
|
354
|
+
if (existing.pid === process.pid || await fetchIngestProbe()) return existing
|
|
355
|
+
if (existingEntry) await stopPid(existingEntry)
|
|
356
|
+
}
|
|
357
|
+
if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) await stopPid(existingEntry)
|
|
358
|
+
if (existing.service !== null && existing.reason) {
|
|
359
|
+
throw new Error(existing.reason)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const lock = await acquireStartupLock()
|
|
363
|
+
let spawnedPid: number | null = null
|
|
364
|
+
let spawnedIdentity: string | null = null
|
|
365
|
+
try {
|
|
366
|
+
// Same reasoning for the post-lock re-check: another ensure()
|
|
367
|
+
// may have spawned a daemon between our first probe and the
|
|
368
|
+
// lock grant, and its initial health response can be slow
|
|
369
|
+
// while the runtime warms up.
|
|
370
|
+
const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
|
|
371
|
+
if (rechecked.managed && rechecked.running) {
|
|
372
|
+
if (rechecked.pid === process.pid || await fetchIngestProbe()) return rechecked
|
|
373
|
+
const recheckedEntry = readRegistryEntry()
|
|
374
|
+
if (recheckedEntry) await stopPid(recheckedEntry)
|
|
375
|
+
}
|
|
376
|
+
if (rechecked.service !== null && rechecked.reason) {
|
|
377
|
+
throw new Error(rechecked.reason)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const logFd = await openLogFile()
|
|
381
|
+
const instanceId = crypto.randomUUID()
|
|
382
|
+
try {
|
|
383
|
+
const proc = Bun.spawn({
|
|
384
|
+
cmd: [process.execPath, "run", config.serverEntry],
|
|
385
|
+
cwd: config.workdir,
|
|
386
|
+
detached: true,
|
|
387
|
+
env: {
|
|
388
|
+
...process.env,
|
|
389
|
+
...expectedEnv(config, instanceId),
|
|
390
|
+
},
|
|
391
|
+
stdio: ["ignore", logFd, logFd],
|
|
392
|
+
})
|
|
393
|
+
spawnedPid = proc.pid
|
|
394
|
+
spawnedIdentity = processIdentity(proc.pid)
|
|
395
|
+
proc.unref()
|
|
396
|
+
} finally {
|
|
397
|
+
fs.closeSync(logFd)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (spawnedPid === null) {
|
|
401
|
+
throw new Error("Daemon failed to spawn.")
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const health = await waitForHealthy(spawnedPid, instanceId)
|
|
405
|
+
return {
|
|
406
|
+
running: true,
|
|
407
|
+
managed: true,
|
|
408
|
+
service: health.service,
|
|
409
|
+
pid: health.pid,
|
|
410
|
+
url: health.url,
|
|
411
|
+
databasePath: health.databasePath,
|
|
412
|
+
workdir: health.workdir,
|
|
413
|
+
startedAt: health.startedAt,
|
|
414
|
+
version: health.version,
|
|
415
|
+
sameWorkdir: workdirMatches(config.workdir, health.workdir),
|
|
416
|
+
reason: null,
|
|
417
|
+
logPath: config.logPath,
|
|
418
|
+
lockPath: config.lockPath,
|
|
419
|
+
registryPid: health.pid,
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (spawnedPid !== null) {
|
|
423
|
+
const entry = readRegistryEntry()
|
|
424
|
+
if (entry?.pid === spawnedPid) {
|
|
425
|
+
await stopPid(entry).catch(() => undefined)
|
|
426
|
+
} else if (spawnedIdentity && processIdentity(spawnedPid) === spawnedIdentity) {
|
|
427
|
+
try { process.kill(spawnedPid, "SIGTERM") } catch { /* already exited */ }
|
|
428
|
+
const deadline = Date.now() + gracefulStopTimeoutMs
|
|
429
|
+
while (Date.now() < deadline && processIdentity(spawnedPid) === spawnedIdentity) await sleep(POLL_INTERVAL_MS)
|
|
430
|
+
if (processIdentity(spawnedPid) === spawnedIdentity) {
|
|
431
|
+
try { process.kill(spawnedPid, "SIGKILL") } catch { /* already exited */ }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
throw error
|
|
436
|
+
} finally {
|
|
437
|
+
await lock.release()
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const stop = async (): Promise<DaemonStatus> => {
|
|
442
|
+
const status = await getStatus()
|
|
443
|
+
if (status.pid === null) return status
|
|
444
|
+
if (status.service !== null && status.service !== MOTEL_SERVICE_ID) {
|
|
445
|
+
throw new Error(`Refusing to stop non-motel service ${status.service} on ${status.url}.`)
|
|
446
|
+
}
|
|
447
|
+
const entry = readRegistryEntry()
|
|
448
|
+
if (!entry || entry.pid !== status.pid) throw new Error(`Refusing to stop pid ${status.pid}: no matching managed registry entry.`)
|
|
449
|
+
await stopPid(entry)
|
|
450
|
+
return await getStatus()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
applyEnv: Effect.sync(() => {
|
|
455
|
+
for (const [key, value] of Object.entries(expectedEnv(config))) {
|
|
456
|
+
process.env[key] = value
|
|
457
|
+
}
|
|
458
|
+
}),
|
|
459
|
+
getStatus: Effect.fn("DaemonManager.getStatus")(() =>
|
|
460
|
+
Effect.tryPromise({
|
|
461
|
+
// Wrapped so Effect.tryPromise only sees the no-arg call
|
|
462
|
+
// signature — the optional timeoutMs parameter is an
|
|
463
|
+
// internal detail used by ensure()'s critical probes.
|
|
464
|
+
try: () => getStatus(),
|
|
465
|
+
catch: mapError,
|
|
466
|
+
}),
|
|
467
|
+
)(),
|
|
468
|
+
ensure: Effect.fn("DaemonManager.ensure")(() =>
|
|
469
|
+
Effect.tryPromise({
|
|
470
|
+
try: ensure,
|
|
471
|
+
catch: mapError,
|
|
472
|
+
}),
|
|
473
|
+
)(),
|
|
474
|
+
stop: Effect.fn("DaemonManager.stop")(() =>
|
|
475
|
+
Effect.tryPromise({
|
|
476
|
+
try: stop,
|
|
477
|
+
catch: mapError,
|
|
478
|
+
}),
|
|
479
|
+
)(),
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
|
|
484
|
+
export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
|
|
485
|
+
export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
|
|
486
|
+
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
|
+
})
|