@aryasaatvik/motel 0.3.1 → 0.3.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryasaatvik/motel",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -98,9 +98,10 @@
98
98
  "typescript": "^6.0.3"
99
99
  },
100
100
  "dependencies": {
101
- "@effect/atom-react": "4.0.0-beta.101",
102
- "@effect/opentelemetry": "4.0.0-beta.101",
103
- "@effect/platform-bun": "4.0.0-beta.101",
101
+ "@effect/atom-react": "4.0.0-rc.110",
102
+ "@effect/opentelemetry": "4.0.0-rc.110",
103
+ "@effect/platform-bun": "4.0.0-rc.110",
104
+ "@effect/platform-node-shared": "4.0.0-rc.110",
104
105
  "@opentelemetry/api": "^1.9.0",
105
106
  "@opentelemetry/exporter-logs-otlp-http": "^0.219.0",
106
107
  "@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
@@ -111,7 +112,7 @@
111
112
  "@opentelemetry/sdk-trace-node": "^2.8.0",
112
113
  "@opentui/core": "0.4.2",
113
114
  "@opentui/react": "0.4.2",
114
- "effect": "4.0.0-beta.101",
115
+ "effect": "4.0.0-rc.110",
115
116
  "protobufjs": "7.6.4",
116
117
  "react": "^19.2.7",
117
118
  "scheduler": "^0.27.0"
@@ -226,6 +226,35 @@ describe("daemon manager", () => {
226
226
  expect(finalStatus.running).toBe(false)
227
227
  })
228
228
 
229
+ test("keeps one ingest generation usable after concurrent client aborts", async () => {
230
+ const harness = makeHarness()
231
+ activeHarnesses.push(harness)
232
+ const started = await Effect.runPromise(harness.manager.ensure)
233
+
234
+ await Promise.all(Array.from({ length: 50 }, async () => {
235
+ const controller = new AbortController()
236
+ const request = fetch(`http://127.0.0.1:${harness.port}/v1/traces`, {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: "{}",
240
+ signal: controller.signal,
241
+ }).catch(() => undefined)
242
+ controller.abort()
243
+ await request
244
+ }))
245
+
246
+ for (const path of ["/v1/traces", "/v1/logs"]) {
247
+ const response = await fetch(`http://127.0.0.1:${harness.port}${path}`, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: "{}",
251
+ })
252
+ expect(response.ok).toBe(true)
253
+ }
254
+ const status = await Effect.runPromise(harness.manager.ensure)
255
+ expect(status.pid).toBe(started.pid)
256
+ })
257
+
229
258
  test("health responds while ingest readiness waits for a write lock", async () => {
230
259
  const harness = makeHarness()
231
260
  activeHarnesses.push(harness)
@@ -45,12 +45,13 @@ const daemonStatus = (overrides: Partial<DaemonStatus> = {}): DaemonStatus => ({
45
45
  ...overrides,
46
46
  })
47
47
 
48
- const makeHarness = (options: { readonly installed?: boolean; readonly missingExecutables?: readonly string[]; readonly plist?: Record<string, unknown>; readonly plutilExitCode?: number; readonly launchctlPrintExitCode?: number; readonly launchctlPrintStderr?: string; readonly bootoutExitCode?: number; readonly health?: DaemonStatus } = {}) => {
48
+ const makeHarness = (options: { readonly installed?: boolean; readonly missingExecutables?: readonly string[]; readonly plist?: Record<string, unknown>; readonly plutilExitCode?: number; readonly launchctlPrintExitCode?: number; readonly launchctlPrintStderr?: string; readonly bootoutExitCode?: number; readonly health?: DaemonStatus; readonly readyAfter?: number } = {}) => {
49
49
  let installed = options.installed ?? false
50
50
  let plist = options.plist ?? plistValue()
51
51
  let launchctlPrintExitCode = options.launchctlPrintExitCode ?? 113
52
52
  const calls: Array<readonly string[]> = []
53
53
  const fileEvents: string[] = []
54
+ let probeCalls = 0
54
55
  const files = new Map<string, string>()
55
56
  const operations: LaunchAgentOperations = {
56
57
  platform: "darwin",
@@ -73,9 +74,10 @@ const makeHarness = (options: { readonly installed?: boolean; readonly missingEx
73
74
  return { exitCode: 0, stdout: "", stderr: "" }
74
75
  },
75
76
  getDaemonStatus: async () => options.health ?? daemonStatus(),
77
+ probeDaemonIngest: async () => ++probeCalls > (options.readyAfter ?? 0),
76
78
  version: "0.2.6",
77
79
  }
78
- return { calls, fileEvents, manager: createLaunchAgentManager(spec, operations) }
80
+ return { calls, fileEvents, get probeCalls() { return probeCalls }, manager: createLaunchAgentManager(spec, operations) }
79
81
  }
80
82
 
81
83
  describe("LaunchAgent specification", () => {
@@ -211,8 +213,9 @@ describe("LaunchAgent lifecycle", () => {
211
213
  })
212
214
 
213
215
  test("bootstraps an unloaded service before restarting it", async () => {
214
- const harness = makeHarness({ installed: true, launchctlPrintExitCode: 113 })
216
+ const harness = makeHarness({ installed: true, launchctlPrintExitCode: 113, readyAfter: 2 })
215
217
  await Effect.runPromise(harness.manager.restart)
218
+ expect(harness.probeCalls).toBe(3)
216
219
  expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
217
220
  ["launchctl", "print", spec.target],
218
221
  ["launchctl", "bootstrap", spec.domain, spec.plistPath],
@@ -276,6 +279,7 @@ describe("LaunchAgent lifecycle", () => {
276
279
  return { exitCode: 0, stdout: "", stderr: "" }
277
280
  },
278
281
  getDaemonStatus: async () => daemonStatus(),
282
+ probeDaemonIngest: async () => true,
279
283
  version: "0.2.6",
280
284
  } satisfies LaunchAgentOperations),
281
285
  })
@@ -50,6 +50,7 @@ export type LaunchAgentOperations = {
50
50
  readonly unlink: (file: string) => Promise<void>
51
51
  readonly run: (command: string, args: readonly string[]) => Promise<CommandResult>
52
52
  readonly getDaemonStatus: () => Promise<DaemonStatus>
53
+ readonly probeDaemonIngest: () => Promise<boolean>
53
54
  readonly version: string
54
55
  }
55
56
 
@@ -206,6 +207,20 @@ export const compareLaunchAgent = (inspection: LaunchAgentInspection, spec: Laun
206
207
 
207
208
  const defaultOperations = (): LaunchAgentOperations => {
208
209
  const daemon = createDaemonManager()
210
+ const probeDaemonIngest = async () => {
211
+ try {
212
+ const post = (path: string) => fetch(`http://127.0.0.1:27686${path}`, {
213
+ method: "POST",
214
+ headers: { "content-type": "application/json" },
215
+ body: "{}",
216
+ signal: AbortSignal.timeout(3_000),
217
+ })
218
+ const [traces, logs] = await Promise.all([post("/v1/traces"), post("/v1/logs")])
219
+ return traces.ok && logs.ok
220
+ } catch {
221
+ return false
222
+ }
223
+ }
209
224
  return {
210
225
  exists: async (file) => fs.access(file).then(() => true).catch(() => false),
211
226
  readFile: (file) => fs.readFile(file, "utf8"),
@@ -219,6 +234,7 @@ const defaultOperations = (): LaunchAgentOperations => {
219
234
  return { exitCode, stdout, stderr }
220
235
  },
221
236
  getDaemonStatus: () => Effect.runPromise(daemon.getStatus),
237
+ probeDaemonIngest,
222
238
  version: MOTEL_VERSION,
223
239
  platform: process.platform,
224
240
  }
@@ -299,6 +315,19 @@ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied
299
315
  }
300
316
  }
301
317
  }
318
+ const waitForReady = async () => {
319
+ const deadline = Date.now() + 30_000
320
+ while (Date.now() < deadline) {
321
+ const health = await operations.getDaemonStatus()
322
+ if (health.running && health.managed && await operations.probeDaemonIngest()) return health
323
+ await Bun.sleep(100)
324
+ }
325
+ throw new LaunchAgentError("Motel LaunchAgent did not become ingest-ready within 30 seconds.")
326
+ }
327
+ const readyStatus = async () => {
328
+ await waitForReady()
329
+ return Effect.runPromise(status)
330
+ }
302
331
  const inspect = Effect.tryPromise({ try: () => readInspection(operations, spec), catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)) })
303
332
  const status = Effect.tryPromise({
304
333
  try: async (): Promise<LaunchAgentStatus> => {
@@ -332,7 +361,7 @@ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied
332
361
  await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
333
362
  await required(operations, "launchctl", ["enable", spec.target])
334
363
  await required(operations, "launchctl", ["kickstart", "-k", spec.target])
335
- return Effect.runPromise(status)
364
+ return readyStatus()
336
365
  }
337
366
  await requireExecutables()
338
367
  if (manager === "loaded" && !replace) throw new LaunchAgentError("Motel has a loaded LaunchAgent job that does not match the requested install state. Re-run with --replace to boot it out before installing.")
@@ -342,7 +371,7 @@ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied
342
371
  await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
343
372
  await required(operations, "launchctl", ["enable", spec.target])
344
373
  await required(operations, "launchctl", ["kickstart", "-k", spec.target])
345
- return Effect.runPromise(status)
374
+ return readyStatus()
346
375
  },
347
376
  catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
348
377
  })
@@ -353,7 +382,7 @@ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied
353
382
  const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
354
383
  if (manager === "not-loaded") await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
355
384
  await required(operations, "launchctl", ["kickstart", "-k", spec.target])
356
- return Effect.runPromise(status)
385
+ return readyStatus()
357
386
  },
358
387
  catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
359
388
  })
@@ -374,7 +403,7 @@ export const createLaunchAgentManager = (spec = buildLaunchAgentSpec(), supplied
374
403
  const manager = requireKnownLaunchctlState(launchctlState(await optional(operations, "launchctl", ["print", spec.target])))
375
404
  if (manager === "not-loaded") await required(operations, "launchctl", ["bootstrap", spec.domain, spec.plistPath])
376
405
  await required(operations, "launchctl", ["kickstart", "-k", spec.target])
377
- return Effect.runPromise(status)
406
+ return readyStatus()
378
407
  },
379
408
  catch: (error) => error instanceof LaunchAgentError ? error : new LaunchAgentError(error instanceof Error ? error.message : String(error)),
380
409
  })
package/src/mcp.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { BunRuntime, BunStdio } from "@effect/platform-bun"
3
3
  import { Effect, Layer, Logger, Schema } from "effect"
4
4
  import { MOTEL_VERSION } from "./registry.js"
5
- import { McpServer, Tool, Toolkit } from "effect/unstable/ai"
5
+ import { McpProtocol, McpServer, Tool, Toolkit } from "effect/unstable/ai"
6
6
  import { TraceSpanStatus } from "./domain.js"
7
7
  import { MotelClient, MotelClientLive } from "./motelClient.js"
8
8
  import { Locator, LocatorLive } from "./locator.js"
@@ -424,6 +424,12 @@ const ServerLayer = McpServer.toolkit(MotelToolkit).pipe(
424
424
  McpServer.layerStdio({
425
425
  name: "motel",
426
426
  version: MOTEL_VERSION,
427
+ protocols: [
428
+ McpProtocol.v2025_11_25,
429
+ McpProtocol.v2025_06_18,
430
+ McpProtocol.v2025_03_26,
431
+ McpProtocol.v2024_11_05,
432
+ ],
427
433
  }),
428
434
  ),
429
435
  Layer.provide(BunStdio.layer),
package/src/motel.test.ts CHANGED
@@ -172,5 +172,5 @@ describe("motel command tree", () => {
172
172
  } finally {
173
173
  fs.rmSync(temporaryPrefix, { recursive: true, force: true })
174
174
  }
175
- })
175
+ }, 20_000)
176
176
  })
package/src/motel.ts CHANGED
@@ -39,7 +39,9 @@ const restart = Command.make("restart", {}, () =>
39
39
  ).pipe(Command.withDescription("Restart only the managed telemetry daemon"))
40
40
 
41
41
  const serviceManager = createLaunchAgentManager()
42
- const serviceInstall = Command.make("install", { replace: Flag.boolean("replace") }, ({ replace }) =>
42
+ const serviceInstall = Command.make("install", {
43
+ replace: Flag.boolean("replace").pipe(Flag.withDefault(false)),
44
+ }, ({ replace }) =>
43
45
  serviceManager.install(replace).pipe(Effect.andThen(json)),
44
46
  ).pipe(Command.withDescription("Install the per-user Motel LaunchAgent"))
45
47
  const serviceStatus = Command.make("status", {}, () =>
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as BunWorker from "@effect/platform-bun/BunWorker"
23
- import { Context, Duration, Effect, Exit, Layer, Scope } 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"
@@ -55,28 +55,26 @@ export const AsyncIngestLive = Layer.effect(
55
55
  // spawn the worker and make /api/health wait on the worker's SQLite
56
56
  // bootstrap. Cache a lazy initializer instead so the worker only starts
57
57
  // on the first ingest request, but is still shared thereafter.
58
- const [getClient, invalidateClient] = yield* Effect.cachedInvalidateWithTTL(Effect.gen(function*() {
58
+ // The server scope owns exactly one lazy writer generation. Request
59
+ // failures must not replace it: concurrent stale failures can otherwise
60
+ // invalidate a newer cached generation and orphan its SQLite writer and
61
+ // maintenance fibers. A poisoned worker stays failed until the managed
62
+ // daemon is restarted, which the external ingest readiness probe handles.
63
+ const getClient = yield* Effect.cached(Effect.gen(function*() {
59
64
  const clientScope = yield* Scope.fork(scope, "sequential")
60
65
  const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
61
- const client = yield* RpcClient.make(IngestRpcs).pipe(
66
+ return yield* RpcClient.make(IngestRpcs).pipe(
62
67
  Effect.provide(protocolContext),
63
68
  Effect.provideService(Scope.Scope, clientScope),
64
69
  )
65
- return { client, clientScope }
66
- }), Duration.infinity)
70
+ }))
67
71
  // Start the sole writer/maintenance worker immediately, but do not make
68
72
  // HTTP health wait for SQLite bootstrap. Managed readiness still verifies
69
73
  // the worker through explicit ingest probes.
70
74
  yield* Effect.forkScoped(getClient.pipe(Effect.ignore))
71
75
  return {
72
- ingestTraces: (input, options) =>
73
- Effect.flatMap(getClient, ({ client, clientScope }) => client.ingestTraces(input, options).pipe(
74
- Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
75
- )),
76
- ingestLogs: (input, options) =>
77
- Effect.flatMap(getClient, ({ client, clientScope }) => client.ingestLogs(input, options).pipe(
78
- Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
79
- )),
76
+ ingestTraces: (input, options) => Effect.flatMap(getClient, (client) => client.ingestTraces(input, options)),
77
+ ingestLogs: (input, options) => Effect.flatMap(getClient, (client) => client.ingestLogs(input, options)),
80
78
  }
81
79
  }),
82
80
  )
@@ -1,5 +1,5 @@
1
1
  import * as BunWorker from "@effect/platform-bun/BunWorker"
2
- import { Duration, Effect, Exit, Layer, Scope } from "effect"
2
+ import { Effect, Layer, Scope } from "effect"
3
3
  import * as RpcClient from "effect/unstable/rpc/RpcClient"
4
4
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
5
5
  import type { WorkerError } from "effect/unstable/workers/WorkerError"
@@ -9,17 +9,14 @@ import { QueryRpcs } from "./queryRpc.js"
9
9
 
10
10
  type QueryMethod = keyof TelemetryStoreReader
11
11
  type QueryClient = RpcClient.FromGroup<typeof QueryRpcs, RpcClientError | WorkerError>
12
- type QueryClientEntry = { readonly client: QueryClient; readonly clientScope: Scope.Scope }
13
12
 
14
13
  const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
15
14
  Layer.provide(RpcSerialization.layerMsgPack),
16
15
  Layer.provide(BunWorker.layer(() => new Worker(new URL("./telemetryQueryWorker.ts", import.meta.url)))),
17
16
  )
18
17
 
19
- const query = <A>(getClient: Effect.Effect<QueryClientEntry, unknown>, invalidateClient: Effect.Effect<void>, method: QueryMethod, args: readonly unknown[] = []) =>
20
- Effect.flatMap(getClient, ({ client, clientScope }) => client.query({ method, args }).pipe(
21
- Effect.onError(() => Effect.andThen(Scope.close(clientScope, Exit.void), invalidateClient)),
22
- )).pipe(
18
+ const query = <A>(getClient: Effect.Effect<QueryClient, unknown>, method: QueryMethod, args: readonly unknown[] = []) =>
19
+ Effect.flatMap(getClient, (client) => client.query({ method, args })).pipe(
23
20
  Effect.map((result) => result as A),
24
21
  Effect.mapError((error) => error instanceof Error ? error : new Error(String(error))),
25
22
  )
@@ -28,16 +25,15 @@ export const TelemetryQueryLive = Layer.effect(
28
25
  TelemetryStoreReadonly,
29
26
  Effect.gen(function*() {
30
27
  const scope = yield* Scope.Scope
31
- const [getClient, invalidateClient] = yield* Effect.cachedInvalidateWithTTL(Effect.gen(function*() {
28
+ const getClient = yield* Effect.cached(Effect.gen(function*() {
32
29
  const clientScope = yield* Scope.fork(scope, "sequential")
33
30
  const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
34
- const client = yield* RpcClient.make(QueryRpcs).pipe(
31
+ return yield* RpcClient.make(QueryRpcs).pipe(
35
32
  Effect.provide(protocolContext),
36
33
  Effect.provideService(Scope.Scope, clientScope),
37
34
  )
38
- return { client, clientScope }
39
- }), Duration.infinity)
40
- const run = <A>(method: QueryMethod, args: readonly unknown[] = []) => query<A>(getClient, invalidateClient, method, args)
35
+ }))
36
+ const run = <A>(method: QueryMethod, args: readonly unknown[] = []) => query<A>(getClient, method, args)
41
37
  return TelemetryStoreReadonly.of({
42
38
  listServices: run("listServices"),
43
39
  listRecentTraces: (serviceName, options) => run("listRecentTraces", [serviceName, options]),
@@ -539,6 +539,9 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
539
539
  `)
540
540
  } else {
541
541
  db.exec(`
542
+ -- Wait for an existing writer to release startup locks before any
543
+ -- pragma that may need to update the database header.
544
+ PRAGMA busy_timeout = 15000;
542
545
  -- Bump cache above the 2MB default. 64MB fits most hot index pages
543
546
  -- (trace_summaries, spans, span_attributes indexes) in RAM even on
544
547
  -- multi-GB databases, cutting cold-read latency meaningfully on
@@ -557,8 +560,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
557
560
  // existing DBs that predate this setting keep their current mode;
558
561
  // Motel never performs a surprise full-file VACUUM at startup.
559
562
  try { db.exec(`PRAGMA auto_vacuum = INCREMENTAL;`) } catch { /* ignore */ }
560
- try {
561
- db.exec(`
563
+ db.exec(`
562
564
  PRAGMA journal_mode = WAL;
563
565
  PRAGMA synchronous = NORMAL;
564
566
  PRAGMA temp_store = MEMORY;
@@ -656,11 +658,7 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
656
658
  key TEXT PRIMARY KEY,
657
659
  value TEXT NOT NULL
658
660
  );
659
- `)
660
- } catch (err) {
661
- if (!isSqliteLockError(err)) throw err
662
- console.warn(`motel: writer bootstrap skipped during startup: ${(err as Error).message}`)
663
- }
661
+ `)
664
662
  }
665
663
 
666
664
  // Tables detected at runtime. For writer connections these flags are
@@ -21,7 +21,7 @@ import { Schema } from "effect"
21
21
  import * as Rpc from "effect/unstable/rpc/Rpc"
22
22
  import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
23
23
 
24
- export class IngestError extends Schema.TaggedErrorClass<IngestError>()("IngestError", {
24
+ export class IngestError extends Schema.TaggedError<IngestError>()("IngestError", {
25
25
  message: Schema.String,
26
26
  }) {}
27
27
 
@@ -2,7 +2,7 @@ import { Schema } from "effect"
2
2
  import * as Rpc from "effect/unstable/rpc/Rpc"
3
3
  import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
4
4
 
5
- export class QueryError extends Schema.TaggedErrorClass<QueryError>()("QueryError", {
5
+ export class QueryError extends Schema.TaggedError<QueryError>()("QueryError", {
6
6
  message: Schema.String,
7
7
  }) {}
8
8