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..f7195e2ae9 100644 --- a/packages/junior/src/api/conversations/README.md +++ b/packages/junior/src/api/conversations/README.md @@ -13,8 +13,17 @@ 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. 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. 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..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"; @@ -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/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 4e7a21af62..9760c7e067 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,89 +834,12 @@ 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(), }) .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 >; @@ -958,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) }) 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(); }