opencode/packages/opencode/src/server/routes/instance/trace.ts
Kit Langton a2bc535367 sketch: typed SessionNotFound (design validation)
Demonstrates the typed-errors-at-the-service-layer pattern on a single
method (`Session.Service.get`):

- Define `Session.SessionNotFound` as `Schema.ErrorClass` with
  `httpApiStatus: 404` and a body shape that matches the existing Hono
  `NamedError` envelope (`name: "NotFoundError"`, `data: { message }`).
- Update `Session.Service.Interface.get` and the implementation to fail
  in the E channel via `yield* new SessionNotFound(...)`.
- HttpApi endpoint declares the typed error directly. The handler drops
  its `mapNotFound` wrapper for `get` — Effect HttpApi auto-routes the
  404 status from the schema annotation and serializes the body fields.
- Hono adapter (`runRequest`) bridges the typed error back to the
  legacy `NotFoundError` defect so the existing `ErrorMiddleware` keeps
  rendering 404 + body until that adapter is retired.

End-to-end validation: the unskipped parity test
(`Error JSON shape parity > HttpApi 404 body matches NamedError shape`)
now passes against the HttpApi adapter.

This is a sketch, not for merge — most cascading consumers are
absorbed via `Effect.orDie` (legacy never-E surface preserved). Real
rollout migrates each method/handler explicitly.
2026-05-03 00:07:01 -04:00

78 lines
2.9 KiB
TypeScript

import type { Context } from "hono"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { NotFoundError } from "@/storage/storage"
import { Session } from "@/session/session"
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
// Build the base span attributes for an HTTP handler: method, path, and every
// matched route param. Names follow OTel attribute-naming guidance:
// domain-first (`session.id`, `message.id`, …) so they match the existing
// OTel `session.id` semantic convention and the bare `message.id` we
// already emit from Tool.execute. Non-standard route params fall back to
// `opencode.<name>` since those are internal implementation details
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
export interface RequestLike {
readonly req: {
readonly method: string
readonly url: string
param(): Record<string, string>
}
}
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
// other param is namespaced under `opencode.` to avoid colliding with
// standard conventions.
export function paramToAttributeKey(key: string): string {
const m = key.match(/^(.+)ID$/)
if (m) return `${m[1].toLowerCase()}.id`
return `opencode.${key}`
}
export function requestAttributes(c: RequestLike): Record<string, string> {
const attributes: Record<string, string> = {
"http.method": c.req.method,
"http.path": new URL(c.req.url).pathname,
}
for (const [key, value] of Object.entries(c.req.param())) {
attributes[paramToAttributeKey(key)] = value
}
return attributes
}
// Bridge typed service errors to the legacy Hono `NamedError` ErrorMiddleware.
// The HttpApi adapter consumes typed errors directly via schema annotations,
// but Hono's ErrorMiddleware switches on `instanceof NamedError`. Until the
// legacy adapter is retired, catch the new typed errors here and rethrow the
// equivalent NamedError so the existing 404 wiring keeps working.
const adaptTypedErrors = <A, E, R>(
self: Effect.Effect<A, E, R>,
): Effect.Effect<A, Exclude<E, Session.SessionNotFound>, R> =>
self.pipe(
Effect.catchIf(
(e: unknown) => e instanceof Session.SessionNotFound,
(e) => Effect.die(new NotFoundError({ message: (e as Session.SessionNotFound).data.message })),
),
) as unknown as Effect.Effect<A, Exclude<E, Session.SessionNotFound>, R>
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
return AppRuntime.runPromise(
adaptTypedErrors(effect).pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })),
)
}
export async function jsonRequest<C extends Context, A, E>(
name: string,
c: C,
effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
) {
return c.json(
await runRequest(
name,
c,
Effect.gen(() => effect(c)),
),
)
}