Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions apps/api/src/routes/internal/ai-sessions.http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// SAFETY-FILE: JSON in this test is emitted by the route under test before its fields are asserted.
import { describe, expect, it } from "@effect/vitest"
import {
AiSessionsInternalApiGroup,
CurrentTenant,
V1SchemaErrors,
V1UnexpectedErrors,
} from "@maple/domain/http"
import { AI_SESSION_SPANS_MAX_SPANS } from "@maple/query-engine-integrations"
import { WarehouseResponseLimitError } from "@maple/query-engine/execution"
import { Context, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService"
import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService"
import { makeWarehouseServiceStub } from "../v2/v2-test-support"
import { V1ErrorBoundaryLive } from "../v1/error-boundary"
import { HttpAiSessionsInternalLive } from "./ai-sessions.http"

/**
* The truncation contract of `POST /internal/ai-sessions/spans`: what the row
* cap does, and what the byte cap does instead. Both are one-off shapes the
* other warehouse reads have no equivalent of.
*/

class AiSessionsOnlyApi extends HttpApi.make("MapleInternalApi")
.add(AiSessionsInternalApiGroup)
.middleware(V1SchemaErrors)
.middleware(V1UnexpectedErrors) {}

const SESSION_ID = "wrun_01KZTEST"

const TENANT = new CurrentTenant.TenantSchema({
orgId: "org_ai_sessions" as CurrentTenant.TenantSchema["orgId"],
userId: "user_ai_sessions" as CurrentTenant.TenantSchema["userId"],
roles: [],
authMode: "self_hosted",
})

const AuthorizationStubLayer = Layer.succeed(
CurrentTenant.SessionAuthorization,
CurrentTenant.SessionAuthorization.of({
bearer: (httpEffect) => Effect.provideService(httpEffect, CurrentTenant.Context, TENANT),
}),
)

/** One warehouse row, in the wire shape `aiSessionSpansRowSchema` decodes. */
const spanRow = (index: number) => ({
traceId: "trace-1",
spanId: `span-${index}`,
parentSpanId: "",
spanName: "chat",
spanKind: "SPAN_KIND_CLIENT",
serviceName: "agent-runner",
durationMs: 12,
statusCode: "Unset",
statusMessage: "",
timestamp: "2026-08-19 10:00:00.000000000",
spanAttributes: { "gen_ai.operation.name": "chat", "maple_ai.session.id": SESSION_ID },
resourceAttributes: {},
})

const makeHarness = (overrides: Partial<WarehouseQueryServiceApi>) => {
const routes = HttpApiBuilder.layer(AiSessionsOnlyApi).pipe(
Layer.provide(HttpAiSessionsInternalLive),
Layer.provide(V1ErrorBoundaryLive),
Layer.provideMerge(AuthorizationStubLayer),
Layer.provideMerge(Layer.succeed(WarehouseQueryService, makeWarehouseServiceStub(overrides))),
)
const { handler, dispose } = HttpRouter.toWebHandler(routes as never, { disableLogger: true })

const spans = async () => {
// SAFETY: the handler's second argument is the Worker environment context,
// and this route reads nothing out of it.
const response = await handler(
new Request("http://maple.test/internal/ai-sessions/spans", {
method: "POST",
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
body: JSON.stringify({
sessionId: SESSION_ID,
startTime: "2026-08-19 09:00:00",
endTime: "2026-08-19 11:00:00",
}),
}),
Context.empty() as never,
)
const text = await response.text()
return {
status: response.status,
body: text.length === 0 ? null : (JSON.parse(text) as Record<string, unknown>),
}
}

return { spans, dispose }
}

describe("POST /internal/ai-sessions/spans", () => {
it("answers a response-limit failure with the 413 the client can act on", async () => {
const harness = makeHarness({
compiledQueryBounded: () =>
Effect.fail(
new WarehouseResponseLimitError({ kind: "bytes", message: "response too large" }),
),
})

try {
const response = await harness.spans()
expect(response.status).toBe(413)
expect(response.body?._tag).toBe("@maple/http/ai-sessions/AiSessionTooLargeError")
} finally {
await harness.dispose()
}
})

it("cuts the session at the row cap and says so", async () => {
// The query asks for one row past the cap precisely so this case is
// distinguishable from a session that exactly fills it.
const rows = Array.from({ length: AI_SESSION_SPANS_MAX_SPANS + 1 }, (_, index) => spanRow(index))
const harness = makeHarness({
compiledQueryBounded: (_tenant, compiled) => compiled.decodeRows(rows).pipe(Effect.orDie),
})

try {
const response = await harness.spans()
expect(response.status).toBe(200)
expect(response.body?.truncated).toBe(true)
expect(response.body?.data).toHaveLength(AI_SESSION_SPANS_MAX_SPANS)
} finally {
await harness.dispose()
}
})

it("reports a session that fits as complete", async () => {
const harness = makeHarness({
compiledQueryBounded: (_tenant, compiled) =>
compiled.decodeRows([spanRow(0), spanRow(1)]).pipe(Effect.orDie),
})

try {
const response = await harness.spans()
expect(response.status).toBe(200)
expect(response.body?.truncated).toBe(false)
expect(response.body?.data).toHaveLength(2)
} finally {
await harness.dispose()
}
})
})
88 changes: 88 additions & 0 deletions apps/api/src/routes/internal/ai-sessions.http.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import { HttpApiBuilder } from "effect/unstable/httpapi"
import {
AiSessionTooLargeError,
CurrentTenant,
GetAiSessionSpansResponse,
ListAiSessionsFacetsResponse,
ListAiSessionsResponse,
MapleInternalApi,
MAX_AI_SESSION_SPANS_RESPONSE_BYTES,
} from "@maple/domain/http"
import type { AiSessionGenAiValues, AiSessionSpan } from "@maple/domain/http"
import { Effect } from "effect"
import { CH } from "@maple/query-engine"
import * as Integrations from "@maple/query-engine-integrations"
import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService"

// The wire span shape is declared in `@maple/domain` and the mapped one in
// `@maple/query-engine-integrations`, because the integrations package depends
// on the domain and cannot be imported back from it. This is the only place
// both are visible, so it is where the claim that they are the same shape gets
// enforced.
type Assert<T extends true> = T
type NoExtraKeys<A, B> = [Exclude<keyof A, keyof B>] extends [never] ? true : false

// Assignability alone would pass a wire struct missing a `gen_ai` field —
// every one of them is optional, so a dropped key satisfies both directions.
// The key sets are compared as well, which is the drift that actually happens:
// a field added to the catalog and not to the wire would silently stop being
// sent. Together they cover both the names and the value types.
type _MappedSpanMatchesWireSpan = Assert<
Integrations.AiAgentSpan extends AiSessionSpan
? AiSessionSpan extends Integrations.AiAgentSpan
? true
: false
: false
>
type _WireCarriesEveryCatalogField = Assert<NoExtraKeys<Integrations.AiGenAiValues, AiSessionGenAiValues>>
type _WireInventsNoField = Assert<NoExtraKeys<AiSessionGenAiValues, Integrations.AiGenAiValues>>
// Same hole one level up: the span's own optional top-level fields (`sessionId`,
// `vendorId`, …) are invisible to assignability for exactly the same reason.
type _SpanKeysMatch = Assert<NoExtraKeys<Integrations.AiAgentSpan, AiSessionSpan>>

/**
* Dashboard-only AI agent session reads.
*
Expand Down Expand Up @@ -72,5 +102,63 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group(
})
}),
)
.handle("spans", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
// One row past the cap: the extra row is what distinguishes a
// session that exactly fills the cap from one whose tail was cut.
const compiled = CH.compile(
Integrations.aiSessionSpansQuery({
limit: Integrations.AI_SESSION_SPANS_MAX_SPANS + 1,
}),
{
orgId: tenant.orgId,
startTime: payload.startTime,
endTime: payload.endTime,
sessionId: payload.sessionId,
},
{ rowSchema: Integrations.aiSessionSpansRowSchema },
)
const rows = yield* warehouse
.compiledQueryBounded(tenant, compiled, {
profile: "list",
context: "aiSessionSpans",
responseLimits: {
maxRows: Integrations.AI_SESSION_SPANS_MAX_SPANS + 1,
maxBytes: MAX_AI_SESSION_SPANS_RESPONSE_BYTES,
},
})
.pipe(
Effect.catchTag(
"@maple/query-engine/execution/WarehouseResponseLimitError",
(error) =>
Effect.fail(
new AiSessionTooLargeError({
sessionId: payload.sessionId,
message: `AI session spans exceeded the ${error.kind} response limit.`,
}),
),
),
)
const truncated = rows.length > Integrations.AI_SESSION_SPANS_MAX_SPANS
yield* Effect.annotateCurrentSpan({
"maple.ai.session_id": payload.sessionId,
"maple.ai.span_count": Math.min(
rows.length,
Integrations.AI_SESSION_SPANS_MAX_SPANS,
),
"maple.ai.truncated": truncated,
})
// Mapped server-side: the raw attribute maps are the dominant
// weight of this read and nothing downstream needs them.
return new GetAiSessionSpansResponse({
data: Integrations.mapAiSpans(
rows.slice(0, Integrations.AI_SESSION_SPANS_MAX_SPANS),
),
truncated,
})
}),
)
}),
)
39 changes: 38 additions & 1 deletion apps/web/src/api/warehouse/ai-sessions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Clock, Effect, Schema } from "effect"
import { ListAiSessionsFacetsRequest, ListAiSessionsRequest } from "@maple/domain/http"
import {
GetAiSessionSpansRequest,
ListAiSessionsFacetsRequest,
ListAiSessionsRequest,
} from "@maple/domain/http"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils"

Expand Down Expand Up @@ -73,3 +77,36 @@ export const getAiSessionsFacets = Effect.fn("AiSessions.aiSessionsFacets")(func
)
return { vendors: result.vendors, services: result.services }
})

// Session spans (detail page)

const AiSessionSpansInput = Schema.Struct({
sessionId: Schema.String.check(Schema.isMinLength(1)),
// No `defaultTimeRange` fallback: the window bounds which spans of the
// session are found at all, so the caller supplies one derived from the
// session it is opening rather than inheriting the list page's 24h default.
startTime: WarehouseDateTimeString,
endTime: WarehouseDateTimeString,
})
export type AiSessionSpansInput = Schema.Schema.Type<typeof AiSessionSpansInput>

export const getAiSessionSpans = Effect.fn("AiSessions.aiSessionSpans")(function* ({
data,
}: {
data: AiSessionSpansInput
}) {
const input = yield* decodeInput(AiSessionSpansInput, data, "aiSessionSpans")
const result = yield* runWarehouseQuery("aiSessionSpans", () =>
Effect.gen(function* () {
const client = yield* MapleInternalAtomClient
return yield* client.aiSessionsInternal.spans({
payload: new GetAiSessionSpansRequest({
sessionId: input.sessionId,
startTime: input.startTime,
endTime: input.endTime,
}),
})
}),
)
return { data: result.data, truncated: result.truncated }
})
14 changes: 11 additions & 3 deletions apps/web/src/components/agent-sessions/agent-sessions-list.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Link } from "@tanstack/react-router"

import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format"
import { formatSessionDuration } from "@maple/ui/lib/replay-format"
import { ChatBubbleSparkleIcon } from "@/components/icons"
Expand Down Expand Up @@ -92,9 +94,15 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) {
? `${vendor} · v${session.vendorVersion}`
: vendor
return (
<div
<Link
key={session.sessionId}
className="relative flex w-full items-center gap-3 border-b border-border px-3 py-2.5 text-left @2xl:gap-4"
to="/agent-sessions/$sessionId"
params={{ sessionId: session.sessionId }}
// The session's own window, carried through so the detail page
// can bound its warehouse read instead of scanning every
// retained partition.
search={{ t: session.startTime, end: session.endTime }}
className="relative flex w-full items-center gap-3 border-b border-border px-3 py-2.5 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset @2xl:gap-4"
>
{/* Errored sessions get a left accent so they can be picked out
while scanning — same signal as the replays list. */}
Expand Down Expand Up @@ -164,7 +172,7 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) {
{formatRelativeTimeOrDate(session.startTime)}
</span>
</div>
</div>
</Link>
)
})}

Expand Down
Loading
Loading