From 31d06e19c27e77c0bec4ec6ee22572fc18e8dda6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:42:23 +0000 Subject: [PATCH 1/3] feat(api): Page the conversation list with a signed cursor GET /api/conversations returns at most 50 root conversations and had no way to read more. Add an optional cursor query param and an optional nextCursor response field. Without cursor the request behaves as before. The cursor holds the lastActivityAt and conversationId of the last row, which match the existing list order, and is HMAC-signed like the event cursor. An invalid cursor returns 400. Refs #2231 Co-Authored-By: Johannes Daxboeck <114897+dingsdax@users.noreply.github.com> --- .../src/content/docs/operate/dashboard.md | 52 ++++----- .../junior/src/api/conversations/README.md | 12 ++- .../junior/src/api/conversations/cursor.ts | 102 ++++++++++++++---- packages/junior/src/api/conversations/list.ts | 69 +++++++++++- .../junior/src/api/conversations/routes.ts | 3 +- .../junior/src/api/schema/conversation.ts | 2 + .../api/conversations/list.test.ts | 32 +++++- 7 files changed, 216 insertions(+), 56 deletions(-) diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md index aaabab6ccc..c0a8682eb9 100644 --- a/packages/docs/src/content/docs/operate/dashboard.md +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -108,32 +108,32 @@ corresponding System routes. The current authenticated product API slices are: -| Endpoint | Purpose | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/api/health` | Dashboard-safe health metadata. | -| `/api/runtime` | Runtime paths, providers, skills, and packages. | -| `/api/plugins` | Loaded plugin list. | -| `/api/plugins/:plugin/*` | Authenticated, namespaced API routes contributed by enabled plugins. | -| `/api/user-pages` | Safe navigation metadata for registered plugin pages. | -| `/api/user-pages/:plugin/:page` | Viewer-authorized metrics and records for one plugin page. | -| `/api/automations` | Viewer-owned scheduled and event automations. | -| `/api/automations/:id` | Deletes one viewer-owned scheduled or event automation. | -| `/api/skills` | Discovered skill list. | -| `/api/conversations` | Recent SQL conversation feed; optional `actorEmail` is a normalized presentation filter, not authorization; optional `codeRepositoryId` lists conversations linked to that repository's code changes. | -| `/api/conversations/stats` | Complete 90-day conversation stats and people/place leaderboards aggregated by SQL. | -| `/api/code` | Native code-change overview across repositories. | -| `/api/code/repositories/:repository` | One repository's code-change summary, recent changes, and Workspaces. | -| `/api/people` | Actor directory. | -| `/api/people/:email` | Actor profile and recent conversation resources. | -| `/api/people/:email/code` | Person-scoped native code-change summary for one profile. | -| `/api/people/:email/plugin-reports` | Person-scoped sanitized plugin operational summaries. | -| `/api/locations` | Public location directory and generic private-activity totals. | -| `/api/locations/:location` | Activity, actors, and recent conversations for one public location. | -| `/api/plugin-reports` | Sanitized plugin operational summaries. | -| `/api/conversations/:conversation` | Conversation header metadata and expiring transcript; private transcripts are participant-authorized. | -| `/api/conversations/:conversation/events` | Earlier transcript event pages selected with a signed `before` cursor. | -| `/api/config` | Safe dashboard config signals and feature readiness. | -| `/api/me` | Signed-in dashboard identity. | +| Endpoint | Purpose | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/api/health` | Dashboard-safe health metadata. | +| `/api/runtime` | Runtime paths, providers, skills, and packages. | +| `/api/plugins` | Loaded plugin list. | +| `/api/plugins/:plugin/*` | Authenticated, namespaced API routes contributed by enabled plugins. | +| `/api/user-pages` | Safe navigation metadata for registered plugin pages. | +| `/api/user-pages/:plugin/:page` | Viewer-authorized metrics and records for one plugin page. | +| `/api/automations` | Viewer-owned scheduled and event automations. | +| `/api/automations/:id` | Deletes one viewer-owned scheduled or event automation. | +| `/api/skills` | Discovered skill list. | +| `/api/conversations` | Recent SQL conversation feed; optional `actorEmail` is a normalized presentation filter, not authorization; optional `codeRepositoryId` lists conversations linked to that repository's code changes; `nextCursor` pages past 50 rows via `cursor`. | +| `/api/conversations/stats` | Complete 90-day conversation stats and people/place leaderboards aggregated by SQL. | +| `/api/code` | Native code-change overview across repositories. | +| `/api/code/repositories/:repository` | One repository's code-change summary, recent changes, and Workspaces. | +| `/api/people` | Actor directory. | +| `/api/people/:email` | Actor profile and recent conversation resources. | +| `/api/people/:email/code` | Person-scoped native code-change summary for one profile. | +| `/api/people/:email/plugin-reports` | Person-scoped sanitized plugin operational summaries. | +| `/api/locations` | Public location directory and generic private-activity totals. | +| `/api/locations/:location` | Activity, actors, and recent conversations for one public location. | +| `/api/plugin-reports` | Sanitized plugin operational summaries. | +| `/api/conversations/:conversation` | Conversation header metadata and expiring transcript; private transcripts are participant-authorized. | +| `/api/conversations/:conversation/events` | Earlier transcript event pages selected with a signed `before` cursor. | +| `/api/config` | Safe dashboard config signals and feature readiness. | +| `/api/me` | Signed-in dashboard identity. | For a private conversation, the transcript is available only when the signed-in verified email matches the owning root conversation actor's persisted verified email. Conversation summary resources report that match through `isParticipant`; event-page resources apply the same authorization decision through their projected events and `eventHistory`. Other viewers receive redacted metadata. Top-level conversation resources and System, People, and Location statistics include descendant duration and usage even when child resources are loaded separately. A directly requested child reports only its own metrics. diff --git a/packages/junior/src/api/conversations/README.md b/packages/junior/src/api/conversations/README.md index b2184d91c4..fc52c75d9d 100644 --- a/packages/junior/src/api/conversations/README.md +++ b/packages/junior/src/api/conversations/README.md @@ -13,8 +13,16 @@ resources: - `/events` reads strictly before a `before` cursor and returns the next older bounded page. -Cursors are opaque, HMAC-signed, and bound to one conversation and canonical -event sequence. Callers must not derive or modify cursor positions. +Event cursors are bound to one conversation and canonical event sequence. + +The conversation list returns at most 50 root conversations. `nextCursor` is +present when more remain. Send it back as `cursor` to read the next page. A +list cursor holds the `lastActivityAt` and `conversationId` of the last row, in +the same order as the list. A conversation with new activity moves above the +cursor, so later pages do not show it. + +All cursors are opaque and HMAC-signed. Callers must not derive or modify +cursor positions. Canonical storage contains runtime and Pi-shaped events that are not a suitable REST contract. The reporting adapter projects those facts into normalized diff --git a/packages/junior/src/api/conversations/cursor.ts b/packages/junior/src/api/conversations/cursor.ts index f18677c3d1..c5a9cf9538 100644 --- a/packages/junior/src/api/conversations/cursor.ts +++ b/packages/junior/src/api/conversations/cursor.ts @@ -1,7 +1,8 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { z } from "zod"; -const CURSOR_CONTEXT = "junior-conversation-report-cursor"; +const EVENT_CURSOR_CONTEXT = "junior-conversation-report-cursor"; +const FEED_CURSOR_CONTEXT = "junior-conversation-feed-cursor"; const conversationCursorPayloadSchema = z .object({ @@ -15,6 +16,18 @@ type ConversationCursorPayload = z.infer< typeof conversationCursorPayloadSchema >; +const conversationFeedCursorPayloadSchema = z + .object({ + conversationId: z.string().min(1), + lastActivityAtMs: z.number().int(), + version: z.literal(1), + }) + .strict(); + +type ConversationFeedCursorPayload = z.infer< + typeof conversationFeedCursorPayloadSchema +>; + function cursorSecret(): string { const secret = process.env.JUNIOR_SECRET?.trim(); if (!secret) { @@ -24,45 +37,88 @@ function cursorSecret(): string { } /** Sign one encoded cursor with the domain-separated server secret. */ -function signature(payload: string): string { +function signature(context: string, payload: string): string { return createHmac("sha256", cursorSecret()) - .update(`${CURSOR_CONTEXT}:${payload}`) + .update(`${context}:${payload}`) .digest("base64url"); } -/** Encode an authenticated, conversation-bound event position for an API cursor. */ -export function encodeConversationCursor( - payload: Omit, +function encodeSignedCursor( + context: string, + payload: ConversationCursorPayload | ConversationFeedCursorPayload, ): string { - const encoded = Buffer.from( - JSON.stringify({ ...payload, version: 1 }), - "utf8", - ).toString("base64url"); - return `${encoded}.${signature(encoded)}`; + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString( + "base64url", + ); + return `${encoded}.${signature(context, encoded)}`; } -/** Decode an authenticated reporting cursor for the expected conversation. */ -export function decodeConversationCursor(args: { - conversationId: string; - cursor: string; -}): { seq: number } | undefined { - const [encoded, actualSignature, extra] = args.cursor.split("."); +/** Return the parsed payload only when the cursor signature is valid. */ +function decodeSignedCursor( + context: string, + cursor: string, + schema: z.ZodType, +): T | undefined { + const [encoded, actualSignature, extra] = cursor.split("."); if (!encoded || !actualSignature || extra !== undefined) return undefined; - const expected = Buffer.from(signature(encoded)); + const expected = Buffer.from(signature(context, encoded)); const actual = Buffer.from(actualSignature); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { return undefined; } try { - const parsed = conversationCursorPayloadSchema.parse( + return schema.parse( JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")), ); - if (parsed.conversationId !== args.conversationId) { - return undefined; - } - return { seq: parsed.seq }; } catch { return undefined; } } + +/** Encode an authenticated, conversation-bound event position for an API cursor. */ +export function encodeConversationCursor( + payload: Omit, +): string { + return encodeSignedCursor(EVENT_CURSOR_CONTEXT, { ...payload, version: 1 }); +} + +/** Decode an authenticated reporting cursor for the expected conversation. */ +export function decodeConversationCursor(args: { + conversationId: string; + cursor: string; +}): { seq: number } | undefined { + const parsed = decodeSignedCursor( + EVENT_CURSOR_CONTEXT, + args.cursor, + conversationCursorPayloadSchema, + ); + if (!parsed || parsed.conversationId !== args.conversationId) { + return undefined; + } + return { seq: parsed.seq }; +} + +/** Encode the last returned feed row as an authenticated next-page cursor. */ +export function encodeConversationFeedCursor( + position: Omit, +): string { + return encodeSignedCursor(FEED_CURSOR_CONTEXT, { ...position, version: 1 }); +} + +/** Decode an authenticated feed cursor into the last row of the prior page. */ +export function decodeConversationFeedCursor( + cursor: string, +): Omit | undefined { + const parsed = decodeSignedCursor( + FEED_CURSOR_CONTEXT, + cursor, + conversationFeedCursorPayloadSchema, + ); + return parsed + ? { + conversationId: parsed.conversationId, + lastActivityAtMs: parsed.lastActivityAtMs, + } + : undefined; +} diff --git a/packages/junior/src/api/conversations/list.ts b/packages/junior/src/api/conversations/list.ts index ac960fde34..8acba19783 100644 --- a/packages/junior/src/api/conversations/list.ts +++ b/packages/junior/src/api/conversations/list.ts @@ -1,5 +1,16 @@ import type { User } from "@sentry/junior-plugin-api"; -import { and, asc, desc, eq, isNull, or, sql, type SQL } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + gt, + isNull, + lt, + or, + sql, + type SQL, +} from "drizzle-orm"; import { getDb } from "@/chat/db"; import type { Conversation } from "@/chat/conversations/store"; import { locationFromRow } from "@/chat/conversations/sql/location"; @@ -18,6 +29,11 @@ import { resolveSlackTeamDomains } from "@/chat/slack/team-domain"; import { readAutomationTitlesByConversationId } from "@/chat/automations/execution-stats"; import { conversationSummaryFromStoredConversation } from "./projection"; import { readConversationAccessFromSql } from "./access"; +import { + decodeConversationFeedCursor, + encodeConversationFeedCursor, +} from "./cursor"; +import { throwApiError } from "../http"; import { conversationArchivedForEmail, conversationArchivedForUser, @@ -111,6 +127,25 @@ function conversationCodeRepositoryFilter( ); } +/** + * Keep rows after one feed position in `lastActivityAt DESC, conversationId + * ASC` order. Writers store `lastActivityAt` from millisecond timestamps, so + * the cursor keeps the exact value. + */ +function conversationFeedAfter(after: { + conversationId: string; + lastActivityAtMs: number; +}): SQL | undefined { + const lastActivityAt = new Date(after.lastActivityAtMs); + return or( + lt(juniorConversations.lastActivityAt, lastActivityAt), + and( + eq(juniorConversations.lastActivityAt, lastActivityAt), + gt(juniorConversations.conversationId, after.conversationId), + ), + ); +} + async function conversationRows( db: JuniorDatabase, limit: number, @@ -119,6 +154,7 @@ async function conversationRows( query?: string, includePrivateBriefs = false, codeRepository?: { id: string; viewerUserId?: string }, + after?: { conversationId: string; lastActivityAtMs: number }, ) { return db .select({ @@ -146,6 +182,7 @@ async function conversationRows( and( isNull(juniorConversations.parentConversationId), conversationFeedMembershipFilter(status, filter), + after ? conversationFeedAfter(after) : undefined, codeRepository ? conversationCodeRepositoryFilter( codeRepository.id, @@ -322,11 +359,17 @@ function conversationFeedFilter(options: { * `codeRepositoryId` replaces membership with every root linked to that * repository's code changes. Private roots appear only for their members, and * per-person archive state does not hide rows. + * + * `cursor` resumes after the last row of a prior page. `nextCursor` is set only + * when more rows remain. `lastActivityAt` only moves forward, so a conversation + * with new activity during paging moves above the cursor and later pages skip + * it. */ export async function readConversationFeedFromSql( options: { actorEmail?: string; codeRepositoryId?: string; + cursor?: string; limit?: number; q?: string; status?: "active" | "archived"; @@ -339,9 +382,17 @@ export async function readConversationFeedFromSql( ? undefined : conversationFeedFilter(options); const query = options.q?.trim().toLowerCase() || undefined; - const rows = await conversationRows( + const after = options.cursor + ? decodeConversationFeedCursor(options.cursor) + : undefined; + if (options.cursor && !after) { + throwApiError(400, "Invalid conversation cursor."); + } + const limit = options.limit ?? CONVERSATION_FEED_LIMIT; + // Read one extra row to learn whether another page exists. + const pageRows = await conversationRows( db, - options.limit ?? CONVERSATION_FEED_LIMIT, + limit + 1, query || options.codeRepositoryId ? "all" : (options.status ?? "active"), filter, query, @@ -352,7 +403,10 @@ export async function readConversationFeedFromSql( ...(options.viewer ? { viewerUserId: options.viewer.id } : undefined), } : undefined, + after, ); + const rows = pageRows.slice(0, limit); + const lastRow = pageRows.length > limit ? rows.at(-1) : undefined; const conversations = rows.map((row) => conversationFromRow(row)); const conversationIds = conversations.map( (conversation) => conversation.conversationId, @@ -488,6 +542,14 @@ export async function readConversationFeedFromSql( ), }; }), + ...(lastRow + ? { + nextCursor: encodeConversationFeedCursor({ + conversationId: lastRow.conversation.conversationId, + lastActivityAtMs: lastRow.conversation.lastActivityAt.getTime(), + }), + } + : undefined), generatedAt: new Date(nowMs).toISOString(), source: "conversation_index", }; @@ -501,6 +563,7 @@ export async function readConversationFeed( options: { actorEmail?: string; codeRepositoryId?: string; + cursor?: string; q?: string; status?: "active" | "archived"; viewer?: User; diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index 0b08f921ac..fd68f77cd6 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -67,7 +67,7 @@ export function createConversationRoutes( "Invalid query parameters.", ), async (context) => { - const { actorEmail, codeRepositoryId, q, status } = + const { actorEmail, codeRepositoryId, cursor, q, status } = context.req.valid("query"); const viewer = context.get("viewer"); return jsonResponse( @@ -75,6 +75,7 @@ export function createConversationRoutes( await readConversationFeed({ ...(actorEmail ? { actorEmail } : undefined), ...(codeRepositoryId ? { codeRepositoryId } : undefined), + ...(cursor ? { cursor } : undefined), ...(q ? { q } : undefined), status, ...(viewer ? { viewer } : undefined), diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 4e7a21af62..3e83e2019d 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -75,6 +75,7 @@ export const conversationFeedQuerySchema = z .transform((value) => value.toLowerCase()) .optional(), codeRepositoryId: z.string().uuid().optional(), + cursor: z.string().min(1).optional(), q: z.string().trim().max(200).optional(), status: z.enum(["active", "archived"]).default("active"), }) @@ -833,6 +834,7 @@ export const conversationEventPageSchema = z export const conversationFeedSchema = z .object({ conversations: z.array(conversationSummaryReportSchema), + nextCursor: z.string().min(1).optional(), source: z.literal("conversation_index"), generatedAt: z.string(), }) diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index aed4920a23..a74233468b 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -153,6 +153,13 @@ describe("conversation list API", () => { expect(apiErrorSchema.parse(await invalid.json())).toEqual({ error: "Invalid query parameters.", }); + const invalidCursor = await app.request( + "http://localhost/api/conversations?cursor=not-a-cursor", + ); + expect(invalidCursor.status).toBe(400); + expect(apiErrorSchema.parse(await invalidCursor.json())).toEqual({ + error: "Invalid conversation cursor.", + }); } finally { await fixture.close(); } @@ -540,7 +547,8 @@ describe("conversation list API", () => { teamId: "T1", channelId: "D1", }, - nowMs: 2_000, + // Ties morgan-newest so paging must order by conversation id. + nowMs: 3_000, source: "slack", visibility: "private", }); @@ -608,6 +616,28 @@ describe("conversation list API", () => { }), ], }); + + const pagedIds: string[] = []; + let cursor: string | undefined; + do { + const page = await readConversationFeedFromSql({ + ...(cursor ? { cursor } : undefined), + limit: 1, + viewer, + }); + pagedIds.push( + ...page.conversations.map( + (conversation) => conversation.conversationId, + ), + ); + cursor = page.nextCursor; + } while (cursor && pagedIds.length < 4); + expect(pagedIds).toEqual([ + "slack:C1:morgan-newest", + "slack:D1:morgan-linked", + "slack:C1:morgan-older", + ]); + expect(cursor).toBeUndefined(); } finally { await fixture.close(); } From 9afc633855a0f6b5e6bb160162f2498a6764ea76 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:49:46 +0000 Subject: [PATCH 2/3] docs(api): Say that list cursors need the same filters Co-Authored-By: Johannes Daxboeck <114897+dingsdax@users.noreply.github.com> --- packages/junior/src/api/conversations/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/api/conversations/README.md b/packages/junior/src/api/conversations/README.md index fc52c75d9d..f7195e2ae9 100644 --- a/packages/junior/src/api/conversations/README.md +++ b/packages/junior/src/api/conversations/README.md @@ -19,7 +19,8 @@ The conversation list returns at most 50 root conversations. `nextCursor` is present when more remain. Send it back as `cursor` to read the next page. A list cursor holds the `lastActivityAt` and `conversationId` of the last row, in the same order as the list. A conversation with new activity moves above the -cursor, so later pages do not show it. +cursor, so later pages do not show it. The cursor holds only a position. Send +the same filters with `cursor` as in the first request. All cursors are opaque and HMAC-signed. Callers must not derive or modify cursor positions. From c0835e746e310f7aea97aa2d34e3338b8dc7bc97 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:00:00 +0000 Subject: [PATCH 3/3] ref(api): Move conversation stats schemas to their own module The cursor fields pushed schema/conversation.ts past the 1,000-line limit. Conversation stats are a separate resource (/api/conversations/stats), so move their schemas and types to schema/conversation-stats.ts. The public schema exports do not change. --- .../junior/src/api/conversations/routes.ts | 2 +- .../src/api/conversations/stats.query.ts | 2 +- .../junior/src/api/conversations/stats.ts | 4 +- packages/junior/src/api/schema.ts | 12 +-- .../src/api/schema/conversation-stats.ts | 87 +++++++++++++++++++ .../junior/src/api/schema/conversation.ts | 85 ------------------ packages/junior/src/api/schema/location.ts | 2 +- packages/junior/src/api/schema/person.ts | 2 +- 8 files changed, 100 insertions(+), 96 deletions(-) create mode 100644 packages/junior/src/api/schema/conversation-stats.ts diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index fd68f77cd6..9bb4ce0f06 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -19,10 +19,10 @@ import { conversationFeedSchema, conversationParamsSchema, conversationPendingMessagesReportSchema, - conversationStatsReportSchema, createConversationBodySchema, createConversationMessageBodySchema, } from "../schema/conversation"; +import { conversationStatsReportSchema } from "../schema/conversation-stats"; import { stopConversationTurnResponseSchema } from "../schema/conversation-stop"; import { conversationChildAgentsReportSchema } from "../schema/child-agent"; import { validateRequest } from "../validation"; diff --git a/packages/junior/src/api/conversations/stats.query.ts b/packages/junior/src/api/conversations/stats.query.ts index 9ca236fad5..71376b4fc8 100644 --- a/packages/junior/src/api/conversations/stats.query.ts +++ b/packages/junior/src/api/conversations/stats.query.ts @@ -20,7 +20,7 @@ import type { ConversationStatsReport, GuardianMetricDay, GuardianStats, -} from "../schema/conversation"; +} from "../schema/conversation-stats"; import { WINDOW_SEVEN_DAY_HOURS, sumUtcHoursIntoSixHours, diff --git a/packages/junior/src/api/conversations/stats.ts b/packages/junior/src/api/conversations/stats.ts index 0b1f3af0c1..c98d6e46b2 100644 --- a/packages/junior/src/api/conversations/stats.ts +++ b/packages/junior/src/api/conversations/stats.ts @@ -1,6 +1,6 @@ import { readConversationStatsFromSql } from "./stats.query"; -import { conversationStatsReportSchema } from "../schema/conversation"; -import type { ConversationStatsReport } from "../schema/conversation"; +import { conversationStatsReportSchema } from "../schema/conversation-stats"; +import type { ConversationStatsReport } from "../schema/conversation-stats"; /** Load aggregate conversation stats directly from durable SQL records. */ export async function readConversationStats(): Promise { diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 12e8b11fdb..c8190cc124 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -60,7 +60,6 @@ export { conversationReportEventSchema, conversationTurnFailureCodeSchema, conversationTurnFailureReasonSchema, - conversationStatsReportSchema, conversationSummaryReportSchema, createConversationBodySchema, createConversationMessageBodySchema, @@ -90,20 +89,23 @@ export type { ConversationReportStatus, ConversationTurnFailureCode, ConversationTurnFailureReason, - ConversationMetricDay, ConversationModelUsage, ConversationParams, ConversationSourceTask, - ConversationStatsItem, - ConversationStatsReport, ConversationSummaryReport, ConversationSurface, ConversationUsage, CreateConversationBody, CreateConversationMessageBody, +} from "./schema/conversation"; +export { conversationStatsReportSchema } from "./schema/conversation-stats"; +export type { + ConversationStatsItem, + ConversationMetricDay, GuardianMetricDay, GuardianStats, -} from "./schema/conversation"; + ConversationStatsReport, +} from "./schema/conversation-stats"; export { conversationChildAgentSchema, conversationChildAgentsReportSchema, diff --git a/packages/junior/src/api/schema/conversation-stats.ts b/packages/junior/src/api/schema/conversation-stats.ts new file mode 100644 index 0000000000..1cf754cfec --- /dev/null +++ b/packages/junior/src/api/schema/conversation-stats.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +export const conversationStatsItemSchema = z + .object({ + active: z.number(), + conversations: z.number(), + durationMs: z.number(), + failed: z.number(), + label: z.string(), + costUsd: z.number().optional(), + tokens: z.number().optional(), + }) + .strict(); + +/** UTC day (`YYYY-MM-DD`) or hour (`YYYY-MM-DDTHH`) activity bucket key. */ +export const conversationMetricBucketSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/); + +export const conversationMetricDaySchema = z + .object({ + cachedInputTokens: z.number().optional(), + cacheCreationTokens: z.number().optional(), + conversations: z.number(), + costUsd: z.number().optional(), + date: conversationMetricBucketSchema, + durationMs: z.number(), + inputTokens: z.number().optional(), + tokens: z.number().optional(), + }) + .strict(); + +export const guardianMetricDaySchema = z + .object({ + allow: z.number(), + ask: z.number(), + costUsd: z.number().optional(), + date: conversationMetricBucketSchema, + deny: z.number(), + requests: z.number(), + }) + .strict(); + +export const guardianStatsSchema = z + .object({ + allow: z.number(), + ask: z.number(), + costUsd: z.number().optional(), + deny: z.number(), + metricDays: z.array(guardianMetricDaySchema), + metricHours: z.array(guardianMetricDaySchema).optional(), + metricSixHours: z.array(guardianMetricDaySchema).optional(), + requests: z.number(), + }) + .strict(); + +export const conversationStatsReportSchema = z + .object({ + active: z.number(), + cachedInputTokens: z.number().optional(), + cacheCreationTokens: z.number().optional(), + conversations: z.number(), + durationMs: z.number(), + failed: z.number(), + generatedAt: z.string(), + guardian: guardianStatsSchema, + metricDays: z.array(conversationMetricDaySchema), + metricHours: z.array(conversationMetricDaySchema).optional(), + metricSixHours: z.array(conversationMetricDaySchema).optional(), + locations: z.array(conversationStatsItemSchema), + actors: z.array(conversationStatsItemSchema), + source: z.literal("conversation_index"), + costUsd: z.number().optional(), + inputTokens: z.number().optional(), + tokens: z.number().optional(), + windowEnd: z.string(), + windowStart: z.string(), + }) + .strict(); + +export type ConversationStatsItem = z.infer; +export type ConversationMetricDay = z.infer; +export type GuardianMetricDay = z.infer; +export type GuardianStats = z.infer; +export type ConversationStatsReport = z.infer< + typeof conversationStatsReportSchema +>; diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 3e83e2019d..9760c7e067 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -840,84 +840,6 @@ export const conversationFeedSchema = z }) .strict(); -export const conversationStatsItemSchema = z - .object({ - active: z.number(), - conversations: z.number(), - durationMs: z.number(), - failed: z.number(), - label: z.string(), - costUsd: z.number().optional(), - tokens: z.number().optional(), - }) - .strict(); - -/** UTC day (`YYYY-MM-DD`) or hour (`YYYY-MM-DDTHH`) activity bucket key. */ -export const conversationMetricBucketSchema = z - .string() - .regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/); - -export const conversationMetricDaySchema = z - .object({ - cachedInputTokens: z.number().optional(), - cacheCreationTokens: z.number().optional(), - conversations: z.number(), - costUsd: z.number().optional(), - date: conversationMetricBucketSchema, - durationMs: z.number(), - inputTokens: z.number().optional(), - tokens: z.number().optional(), - }) - .strict(); - -export const guardianMetricDaySchema = z - .object({ - allow: z.number(), - ask: z.number(), - costUsd: z.number().optional(), - date: conversationMetricBucketSchema, - deny: z.number(), - requests: z.number(), - }) - .strict(); - -export const guardianStatsSchema = z - .object({ - allow: z.number(), - ask: z.number(), - costUsd: z.number().optional(), - deny: z.number(), - metricDays: z.array(guardianMetricDaySchema), - metricHours: z.array(guardianMetricDaySchema).optional(), - metricSixHours: z.array(guardianMetricDaySchema).optional(), - requests: z.number(), - }) - .strict(); - -export const conversationStatsReportSchema = z - .object({ - active: z.number(), - cachedInputTokens: z.number().optional(), - cacheCreationTokens: z.number().optional(), - conversations: z.number(), - durationMs: z.number(), - failed: z.number(), - generatedAt: z.string(), - guardian: guardianStatsSchema, - metricDays: z.array(conversationMetricDaySchema), - metricHours: z.array(conversationMetricDaySchema).optional(), - metricSixHours: z.array(conversationMetricDaySchema).optional(), - locations: z.array(conversationStatsItemSchema), - actors: z.array(conversationStatsItemSchema), - source: z.literal("conversation_index"), - costUsd: z.number().optional(), - inputTokens: z.number().optional(), - tokens: z.number().optional(), - windowEnd: z.string(), - windowStart: z.string(), - }) - .strict(); - export type ConversationReportStatus = z.infer< typeof conversationReportStatusSchema >; @@ -960,13 +882,6 @@ export type ConversationDetailReport = z.infer< >; export type ConversationEventPage = z.infer; export type ConversationFeed = z.infer; -export type ConversationStatsItem = z.infer; -export type ConversationMetricDay = z.infer; -export type GuardianMetricDay = z.infer; -export type GuardianStats = z.infer; -export type ConversationStatsReport = z.infer< - typeof conversationStatsReportSchema ->; export type ConversationParams = z.infer; export type ArchiveConversationBody = z.infer< typeof archiveConversationBodySchema diff --git a/packages/junior/src/api/schema/location.ts b/packages/junior/src/api/schema/location.ts index d4f10d2049..091d03a269 100644 --- a/packages/junior/src/api/schema/location.ts +++ b/packages/junior/src/api/schema/location.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { actorIdentitySchema, - conversationStatsItemSchema, conversationSummaryReportSchema, } from "./conversation"; +import { conversationStatsItemSchema } from "./conversation-stats"; import { dailyConversationActivitySchema } from "../activity"; import { juniorDestinationKindSchema } from "@/db/schema/destinations"; diff --git a/packages/junior/src/api/schema/person.ts b/packages/junior/src/api/schema/person.ts index dc2302e886..46c6a87b56 100644 --- a/packages/junior/src/api/schema/person.ts +++ b/packages/junior/src/api/schema/person.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { actorIdentitySchema, - conversationStatsItemSchema, conversationSummaryReportSchema, } from "./conversation"; +import { conversationStatsItemSchema } from "./conversation-stats"; export const personParamsSchema = z .object({ email: z.string().trim().min(1) })