Skip to content
Merged
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
76 changes: 76 additions & 0 deletions apps/api/src/routes/internal/ai-sessions.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { HttpApiBuilder } from "effect/unstable/httpapi"
import {
CurrentTenant,
ListAiSessionsFacetsResponse,
ListAiSessionsResponse,
MapleInternalApi,
} 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"

/**
* Dashboard-only AI agent session reads.
*
* Serves the Agent Sessions page (behind the `agent_tracing` org rollout flag).
* The flag hides the surface, not the data — scoping is `CurrentTenant`, like
* every other warehouse read.
*/
export const HttpAiSessionsInternalLive = HttpApiBuilder.group(
MapleInternalApi,
"aiSessionsInternal",
(handlers) =>
Effect.gen(function* () {
const warehouse = yield* WarehouseQueryService

return handlers
.handle("list", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
const compiled = CH.compile(
Integrations.aiSessionListQuery({
limit: payload.limit,
vendorIds: payload.vendorIds,
serviceNames: payload.serviceNames,
}),
{ orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime },
{ rowSchema: Integrations.aiSessionListRowSchema },
)
// The row schema already coerces the UInt64 aggregates and decodes
// exactly the response's fields, so rows pass through unmapped
// (unlike listReplays, which re-brands and re-coerces per field).
const rows = yield* warehouse.compiledQuery(tenant, compiled, {
profile: "list",
context: "listAiSessions",
})
return new ListAiSessionsResponse({ data: rows })
}),
)
.handle("facets", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId })
const compiled = CH.compileUnion(
Integrations.aiSessionFacetsQuery(),
{ orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime },
{ rowSchema: Integrations.aiSessionFacetsRowSchema },
)
const rows = yield* warehouse.compiledQuery(tenant, compiled, {
profile: "list",
context: "aiSessionsFacets",
})
// One UNION ALL result carrying both dimensions, split by facetType.
const pick = (facetType: string) =>
rows
.filter((row) => row.facetType === facetType)
.map((row) => ({ name: row.name, count: row.count }))
return new ListAiSessionsFacetsResponse({
vendors: pick("vendor"),
services: pick("service"),
})
}),
)
}),
)
5 changes: 4 additions & 1 deletion apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
import { API_CORS_OPTIONS } from "@/http/api-cors"
import { McpLive } from "@/mcp/app"
import { Env } from "@/platform/Env"
import { HttpAiSessionsInternalLive } from "@/routes/internal/ai-sessions.http"
import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http"
import { HttpAuthLive, HttpAuthPublicLive } from "@/routes/v1/auth.http"
import { HttpBillingLive } from "@/routes/internal/billing.http"
Expand Down Expand Up @@ -97,7 +98,9 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
* which is generated from `MapleApi`.
*/
const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
Layer.provide(Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysInternalLive)),
Layer.provide(
Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysInternalLive, HttpAiSessionsInternalLive),
),
Layer.provide(
Layer.mergeAll(HttpAiTriageLive, HttpBillingLive, HttpChatLive, HttpDemoLive, HttpDigestLive),
),
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/api/warehouse/ai-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Clock, Effect, Schema } from "effect"
import { ListAiSessionsFacetsRequest, ListAiSessionsRequest } from "@maple/domain/http"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils"

import { formatWarehouseDateTime } from "@maple/query-engine"

const ListAiSessionsInput = Schema.Struct({
startTime: Schema.optional(WarehouseDateTimeString),
endTime: Schema.optional(WarehouseDateTimeString),
limit: Schema.optional(Schema.Number),
vendorIds: Schema.optional(Schema.Array(Schema.String)),
serviceNames: Schema.optional(Schema.Array(Schema.String)),
})
export type ListAiSessionsInput = Schema.Schema.Type<typeof ListAiSessionsInput>

const defaultTimeRange = (nowMs: number) => {
return {
startTime: formatWarehouseDateTime(nowMs - 24 * 60 * 60 * 1000),
endTime: formatWarehouseDateTime(nowMs),
}
}

export const listAiSessions = Effect.fn("AiSessions.listAiSessions")(function* ({
data,
}: {
data: ListAiSessionsInput
}) {
const input = yield* decodeInput(ListAiSessionsInput, data ?? {}, "listAiSessions")
const fallback = defaultTimeRange(yield* Clock.currentTimeMillis)
const result = yield* runWarehouseQuery("listAiSessions", () =>
Effect.gen(function* () {
const client = yield* MapleInternalAtomClient
return yield* client.aiSessionsInternal.list({
payload: new ListAiSessionsRequest({
startTime: input.startTime ?? fallback.startTime,
endTime: input.endTime ?? fallback.endTime,
limit: input.limit ?? 50,
vendorIds: input.vendorIds,
serviceNames: input.serviceNames,
}),
})
}),
)
return { data: result.data }
})

// List facets (filter sidebar option counts)

const AiSessionsFacetsInput = Schema.Struct({
startTime: Schema.optional(WarehouseDateTimeString),
endTime: Schema.optional(WarehouseDateTimeString),
})
export type AiSessionsFacetsInput = Schema.Schema.Type<typeof AiSessionsFacetsInput>

export const getAiSessionsFacets = Effect.fn("AiSessions.aiSessionsFacets")(function* ({
data,
}: {
data: AiSessionsFacetsInput
}) {
const input = yield* decodeInput(AiSessionsFacetsInput, data ?? {}, "aiSessionsFacets")
const fallback = defaultTimeRange(yield* Clock.currentTimeMillis)
const result = yield* runWarehouseQuery("aiSessionsFacets", () =>
Effect.gen(function* () {
const client = yield* MapleInternalAtomClient
return yield* client.aiSessionsInternal.facets({
payload: new ListAiSessionsFacetsRequest({
startTime: input.startTime ?? fallback.startTime,
endTime: input.endTime ?? fallback.endTime,
}),
})
}),
)
return { vendors: result.vendors, services: result.services }
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { getRouteApi } from "@tanstack/react-router"

import { Result } from "@/lib/effect-atom"
import {
FilterSection,
SearchableFilterSection,
type FilterOption,
} from "@/components/filters/filter-section"
import {
FilterSidebarBody,
FilterSidebarError,
FilterSidebarFrame,
FilterSidebarHeader,
FilterSidebarLoading,
} from "@/components/filters/filter-sidebar"
import { vendorLabel } from "./agent-sessions-list"

const routeApi = getRouteApi("/agent-sessions/")

/** A selected value absent from the current window stays checkable (count 0). */
function withSelected(options: FilterOption[], selected?: string): FilterOption[] {
if (selected && !options.some((o) => o.name === selected)) {
return [{ name: selected, count: 0 }, ...options]
}
return options
}

interface AgentSessionsFilterSidebarProps {
/**
* Distinct sessions per option, aggregated over the whole window rather than
* over the page of rows the list returned. Deliberately unfiltered, so
* selecting one option leaves the others visible and countable.
*/
facetsResult: Result.Result<
{
readonly vendors: ReadonlyArray<FilterOption>
readonly services: ReadonlyArray<FilterOption>
},
unknown
>
}

export function AgentSessionsFilterSidebar({ facetsResult }: AgentSessionsFilterSidebarProps) {
const navigate = routeApi.useNavigate()
const search = routeApi.useSearch()

// Single-value params: take the last toggled option (switching values
// replaces the prior one; unchecking the only one clears it).
const setSingle = (key: "vendor" | "service", values: string[]) => {
navigate({ search: (prev) => ({ ...prev, [key]: values.at(-1) ?? undefined }) })
}

const clearAllFilters = () => {
navigate({ search: (prev) => ({ ...prev, vendor: undefined, service: undefined }) })
}

const hasActiveFilters = !!search.vendor || !!search.service

return Result.builder(facetsResult)
.onInitial(() => <FilterSidebarLoading sectionCount={2} />)
.onError((error) => <FilterSidebarError error={error} />)
.onSuccess((value, result) => {
const vendors = withSelected([...value.vendors], search.vendor)
const services = withSelected([...value.services], search.service)

return (
<FilterSidebarFrame waiting={result.waiting}>
<FilterSidebarHeader canClear={hasActiveFilters} onClear={clearAllFilters} />
<FilterSidebarBody>
<FilterSection
title="Framework"
options={vendors}
selected={search.vendor ? [search.vendor] : []}
onChange={(vals) => setSingle("vendor", vals)}
getOptionLabel={vendorLabel}
/>

<SearchableFilterSection
title="Service"
options={services}
selected={search.service ? [search.service] : []}
onChange={(vals) => setSingle("service", vals)}
/>

{vendors.length === 0 && services.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">
No sessions in the selected time range
</p>
)}
</FilterSidebarBody>
</FilterSidebarFrame>
)
})
.render()
}
Loading
Loading