diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a41b6e155..a5764f4a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # Changelog + ## 0.271.0 ### New Features ✨ diff --git a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts index d0c808dbfd..dd8e6a8496 100644 --- a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./test"; import { mobile, screenshot } from "./screenshot"; @@ -728,3 +730,169 @@ test("keeps the message in view while earlier history and late content load", as }); await expect(reading).toBeInViewport({ ratio: 1 }); }); + +/** Mock one completed Conversation whose server read position moves forward. */ +async function mockReadPositionConversation( + page: Page, + input: { eventCount: number; lastReadSeq: number }, +) { + const generatedAt = "2026-08-07T11:00:00.000Z"; + const conversationId = "read-position"; + const conversation = { + conversationId, + cumulativeDurationMs: 1_000, + displayTitle: "Read position", + isParticipant: true, + lastProgressAt: generatedAt, + lastSeenAt: generatedAt, + startedAt: generatedAt, + status: "completed", + surface: "internal", + }; + const state = { + conversationId, + eventCount: input.eventCount, + /** Holds detail responses until it resolves. */ + detailGate: Promise.resolve(), + reported: [] as number[], + }; + let stored = input.lastReadSeq; + + await page.route("**/api/conversations", async (route) => { + if (new URL(route.request().url()).pathname !== "/api/conversations") { + await route.fallback(); + return; + } + await route.fulfill({ + json: { + conversations: [conversation], + generatedAt, + source: "conversation_index", + }, + }); + }); + await page.route( + `**/api/conversations/${conversationId}/pending-messages`, + async (route) => { + await route.fulfill({ + json: { conversationId, generatedAt, messages: [] }, + }); + }, + ); + await page.route( + `**/api/conversations/${conversationId}/read-position`, + async (route) => { + const { lastReadSeq } = route.request().postDataJSON() as { + lastReadSeq: number; + }; + state.reported.push(lastReadSeq); + stored = Math.max(stored, lastReadSeq); + await route.fulfill({ json: { lastReadSeq: stored } }); + }, + ); + await page.route(`**/api/conversations/${conversationId}`, async (route) => { + await state.detailGate; + await route.fulfill({ + json: { + ...conversation, + eventHistory: { status: "available" }, + events: Array.from({ length: state.eventCount }, (_, seq) => ({ + createdAt: new Date( + Date.parse(generatedAt) + seq * 1_000, + ).toISOString(), + data: { + type: "message", + messageId: `message-${seq}`, + role: seq % 2 === 0 ? "user" : "assistant", + text: `Transcript message ${seq} with enough content to occupy a visible row.`, + }, + seq, + })), + generatedAt, + readPosition: { lastReadSeq: stored }, + }, + }); + }); + return state; +} + +test("opens where the reader left off and reports what they read", async ({ + page, + dashboard, +}) => { + await page.setViewportSize(mobile); + const mock = await mockReadPositionConversation(page, { + eventCount: 60, + lastReadSeq: 20, + }); + const { reported } = mock; + + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); + const transcript = page.getByLabel("Conversation transcript"); + const marker = transcript.getByRole("separator", { name: "New messages" }); + const firstUnread = transcript.getByText("Transcript message 21 "); + const latest = transcript.getByText("Transcript message 59 "); + await expect(marker).toBeInViewport(); + await expect(firstUnread).toBeInViewport(); + await expect(latest).not.toBeInViewport(); + await screenshot(page, "conversation-unread-marker", { view: "mobile" }); + + // Reading the first screen moves the shared position past the marker. + await expect.poll(() => reported.at(-1) ?? 0).toBeGreaterThan(20); + await page.getByRole("button", { name: "Jump to latest" }).click(); + await expect(latest).toBeInViewport(); + await expect.poll(() => reported.at(-1)).toBe(59); + expect(reported).toEqual([...reported].sort((left, right) => left - right)); + + // Readers who prefer the latest message open there, below the marker. + await page.goto(`${dashboard.baseURL}/settings`); + await page.getByRole("radio", { name: /Latest message/ }).click(); + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); + await expect(latest).toBeInViewport(); + await expect(marker).not.toBeInViewport(); +}); + +test("opens at new messages after a cached reopen and keeps a quick exit", async ({ + page, + dashboard, +}) => { + await page.setViewportSize(mobile); + const mock = await mockReadPositionConversation(page, { + eventCount: 40, + lastReadSeq: 39, + }); + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); + const transcript = page.getByLabel("Conversation transcript"); + const marker = transcript.getByRole("separator", { name: "New messages" }); + await expect(transcript.getByText("Transcript message 39 ")).toBeInViewport(); + await expect(marker).toHaveCount(0); + + // New messages arrive while the reader is away. The reopen shows the cached + // copy first, then the fetch brings the new messages and the marker. + await page.getByRole("link", { name: "Back to conversations" }).click(); + mock.eventCount = 60; + let releaseDetail = () => {}; + mock.detailGate = new Promise((resolve) => { + releaseDetail = resolve; + }); + await page + .getByRole("link", { name: /Read position/ }) + .first() + .click(); + await expect(transcript.getByText("Transcript message 39 ")).toBeVisible(); + await expect(marker).toHaveCount(0); + releaseDetail(); + await expect(marker).toBeInViewport(); + await expect(transcript.getByText("Transcript message 40 ")).toBeInViewport(); + await expect( + transcript.getByText("Transcript message 59 "), + ).not.toBeInViewport(); + + // Leaving right after a scroll still saves the place. + const reportCount = mock.reported.length; + await transcript.getByText("Transcript message 52 ").scrollIntoViewIfNeeded(); + await page.getByRole("link", { name: "Back to conversations" }).click(); + await expect + .poll(() => mock.reported.slice(reportCount).at(-1) ?? 0) + .toBeGreaterThanOrEqual(52); +}); diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index f05218dd05..44e3846d8b 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -36,6 +36,7 @@ import { } from "./auth"; import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes"; import { resetMockConversationArchiveState } from "./mock-reporting/fixtures"; +import { resetMockReadPositions } from "./mock-reporting/read-positions"; import { createMockReportingApi } from "./mock-reporting/routes"; import { DASHBOARD_AVATAR_HEADER_PATH, @@ -71,6 +72,7 @@ const mockDisplayNamesByEmail = new Map(); export function resetMockDashboardState(): void { mockDisplayNamesByEmail.clear(); resetMockConversationArchiveState(); + resetMockReadPositions(); } export interface JuniorDashboardOptions { diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 4848ca386c..65b88a3d32 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -98,6 +98,11 @@ import { } from "../review-desk/reviewItemFormat"; import { useConfirmUnsentAnswers } from "../review-desk/UnsentAnswers"; import { Transcript } from "./TranscriptView"; +import { + readConversationStartPosition, + useMarkConversationRead, + useReadPositionAtOpen, +} from "./transcriptReadPosition"; import { TranscriptLoading } from "./TranscriptLoading"; import type { TranscriptViewMode } from "./transcriptRenderModel"; import { @@ -166,6 +171,8 @@ export function ConversationPage(props: { const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); + const markRead = useMarkConversationRead(conversationId); + const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail); const feedConversation = conversations.find( (item) => item.id === conversationId, ); @@ -484,9 +491,11 @@ export function ConversationPage(props: { hasPreviousPage={detail.hasPreviousPage} historyError={detail.historyError} historyVersion={detail.historyVersion} + lastReadSeq={readAtOpen.lastReadSeq} live={live} loadingPreviousPage={detail.isLoadingPreviousPage} onLoadPreviousPage={detail.loadPreviousPage} + onRead={markRead} onSelectedEventSeqChange={setSelectedEventSeq} pendingMessages={detail.inlineMessages} pinRequestVersion={pinRequestVersion} @@ -497,8 +506,10 @@ export function ConversationPage(props: { search={search} onSearchChange={setSearch} selectedEventSeq={selectedEventSeq} + startAtUnread={readConversationStartPosition() === "unread"} transcript={transcript} turnContextPanel={turnContextPanel} + unread={readAtOpen.unread} view={view} /> diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index 99ed8676da..f13f974cfa 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -49,8 +49,13 @@ import { type RenderedTranscriptEntry, } from "./transcriptRenderModel"; import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch"; -import { TranscriptRow, TranscriptRows } from "./TranscriptRows"; +import { + TranscriptRow, + TranscriptRows, + TranscriptUnreadMarker, +} from "./TranscriptRows"; import { TranscriptTimestampProvider } from "./TranscriptTimestamp"; +import type { TranscriptUnreadRange } from "./transcriptReadPosition"; type TranscriptEntry = ReturnType[number]; type TranscriptContextEntry = Extract; @@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo( /** Sends that history does not have yet. History wins on message id. */ pendingMessages?: readonly ConversationMailboxMessage[]; responding?: boolean; + /** Show the "New" marker before the first row in this range. */ + unread?: TranscriptUnreadRange; }) { // Cards can change while their Message events stay unchanged. const { events, annotations } = props.conversation; @@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo( conversation={props.conversation} messages={messages} responding={props.responding} + unread={props.unread} /> @@ -121,6 +129,7 @@ function SegmentEvents(props: { conversation: ConversationTranscript; messages: TranscriptViewMessage[]; responding?: boolean; + unread?: TranscriptUnreadRange; }) { return ( @@ -134,6 +143,7 @@ function SegmentEvents(props: { onOpenSubagentTranscript={props.onOpenSubagentTranscript} transcript={props.messages} conversation={props.conversation} + unread={props.unread} /> ) : props.conversation.eventHistory.status === "redacted" && props.messages.length > 0 ? ( @@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { }) => void; transcript: TranscriptViewMessage[]; conversation: ConversationTranscript; + unread?: TranscriptUnreadRange; }) { const entries = useMemo( () => groupTranscriptMessages(props.transcript), @@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { ( ReactNode; renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode; renderTool: (entry: TranscriptToolEntry) => ReactNode; + unread?: TranscriptUnreadRange; }) { const search = useTranscriptSearch(); const activityKeys = useRef(new Map()); const claimedActivityKeys = new Set(); const rows: ReactNode[] = []; + // Search results are not a reading order, so they get no marker. + const markerSeq = search.active + ? undefined + : unreadMarkerSeq(props.entries, props.unread); + let unreadMarkerPending = markerSeq !== undefined; + // Entries are in seq order, so the first row that reaches the marker seq + // gets the marker. + const pushRow = (seq: number | undefined, row: ReactNode) => { + if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) { + unreadMarkerPending = false; + rows.push(); + } + rows.push(row); + }; const renderEntry = (entry: TranscriptEntry): ReactNode => { if (entry.kind === "subagent") return props.renderSubagent(entry); @@ -359,8 +386,16 @@ function TranscriptEntryList(props: { keyPrefix: props.keyPrefix, knownKeys: activityKeys.current, }); - rows.push( - + const lastSeq = lastEntrySeq(activityEntries); + // The marker goes before the group when any of it is unread. + pushRow( + lastSeq, + + pushRow( + entry.seq, + {renderEntry(entry)} , ); @@ -391,6 +427,39 @@ function TranscriptEntryList(props: { return rows; } +/** + * Find the seq of the first unread row. Entries are in seq order, and pending + * sends without a seq come last, so a binary search finds the first seq after + * the read position. Rows after the range arrived after open, so they get no + * marker. + */ +function unreadMarkerSeq( + entries: readonly TranscriptEntry[], + unread: TranscriptUnreadRange | undefined, +): number | undefined { + if (!unread) return undefined; + let low = 0; + let high = entries.length; + while (low < high) { + const middle = (low + high) >> 1; + const seq = entries[middle]!.seq; + if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1; + else high = middle; + } + const seq = entries[low]?.seq; + return seq !== undefined && seq <= unread.throughSeq ? seq : undefined; +} + +function lastEntrySeq( + entries: readonly RenderedTranscriptEntry[], +): number | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const seq = entries[index]!.seq; + if (seq !== undefined) return seq; + } + return undefined; +} + /** Keep one activity group mounted while new or historical events extend either edge. */ function stableActivityGroupKey(args: { claimedKeys: Set; diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx index f3540eec7a..2ca947768c 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx @@ -15,17 +15,40 @@ export function TranscriptRows({ ); } +/** + * Mark where unread rows start. The pinning hook can open the transcript here. + */ +export function TranscriptUnreadMarker() { + return ( +
+ + + New + + +
+ ); +} + /** Leave room for control outlines inside the mobile row's paint bounds. */ export function TranscriptRow(props: { /** Stable row id that holds the reader's place while layout changes. */ anchorKey?: string; children: ReactNode; indent?: boolean; + /** Last event sequence in the row. Read positions use it. */ + seq?: number; }) { return (
{props.indent ? (
{props.children}
diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx index 99cb7ee55e..b08cbfa5d3 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx @@ -22,6 +22,10 @@ import { usePinnedTranscriptBottom, } from "./transcriptBottomPinning"; import type { TranscriptViewMode } from "./transcriptRenderModel"; +import { + useTranscriptReadReporter, + type TranscriptUnreadRange, +} from "./transcriptReadPosition"; import { TranscriptSearchProvider } from "./transcriptSearch"; import { TurnContextPanelProvider, @@ -34,9 +38,13 @@ export function Transcript(props: { historyError?: Error | null; historyVersion?: string; live?: boolean; + /** The read position when the Conversation opened. */ + lastReadSeq?: number; loadingPreviousPage?: boolean; pinRequestVersion?: number; onLoadPreviousPage?: () => void; + /** Receive the last event sequence the reader saw in the transcript. */ + onRead?: (seq: number) => void; onSelectedEventSeqChange?(seq: number | undefined): void; /** The reader's own sends to show at the end until history has them. */ pendingMessages?: readonly ConversationMailboxMessage[]; @@ -51,6 +59,10 @@ export function Transcript(props: { transcript?: ConversationTranscript; /** Show turn context in the owner's panel. Without it, a drawer opens. */ turnContextPanel?: TurnContextPanel; + /** Open at the "New" marker instead of the latest message. */ + startAtUnread?: boolean; + /** Rows that were unread when the Conversation opened get a marker. */ + unread?: TranscriptUnreadRange; view?: TranscriptViewMode; }) { const view = props.view ?? "rich"; @@ -64,6 +76,7 @@ export function Transcript(props: { historyVersion: props.historyVersion ?? "empty", juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript), pinRequestVersion: props.pinRequestVersion, + startAtUnread: view === "rich" && props.startAtUnread, view, versions: { rich: transcriptBottomVersion(props.transcript, "rich"), @@ -71,6 +84,14 @@ export function Transcript(props: { }, }); + // Read positions follow the transcript rows, not the event log. Search + // results skip rows, so they do not move the read position. + useTranscriptReadReporter({ + content: bottomPinning.contentElement, + lastReadSeq: props.lastReadSeq, + onRead: props.onRead, + paused: view !== "rich" || search.trim().length > 0, + }); const loadPreviousPage = () => props.onLoadPreviousPage?.(); const previousPageRef = useAutoLoadPreviousPage({ enabled: Boolean( @@ -144,6 +165,7 @@ export function Transcript(props: { conversation={props.transcript} pendingMessages={props.pendingMessages} responding={props.responding ?? props.live ?? false} + unread={props.unread} /> )} diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index ddf47067b9..6cd2409cb9 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -178,9 +178,12 @@ export function conversationDetailQueryOptions( structuralSharing: (previous, next) => { if (!next || typeof next !== "object") return next; if (!previous || typeof previous !== "object") return next; - return reuseConversationEventReferences( + return keepLatestReadPosition( previous as ConversationSnapshot, - next as ConversationSnapshot, + reuseConversationEventReferences( + previous as ConversationSnapshot, + next as ConversationSnapshot, + ), ); }, refetchInterval: (query) => @@ -196,6 +199,20 @@ export function conversationDetailQueryOptions( }); } +/** + * Read positions only move forward. A poll that started before a read report + * finished must not put an older position back in the cache. + */ +function keepLatestReadPosition( + previous: ConversationSnapshot, + next: ConversationSnapshot, +): ConversationSnapshot { + const kept = previous.readPosition; + if (!kept) return next; + if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next; + return { ...next, readPosition: kept }; +} + function outboxActorIdentity( queryClient: QueryClient, ): ActorIdentity | undefined { @@ -704,14 +721,17 @@ export function useConversationData(conversationId: string | undefined) { 0, ...awaitingRows.map((message) => Date.parse(message.createdAt)), ); - const detail = useQuery( - conversationDetailQueryOptions(conversationId, { + const detail = useQuery({ + ...conversationDetailQueryOptions(conversationId, { awaitingMessage: awaitingRows.length > 0, fastPollUntilMs: latestSendAtMs ? latestSendAtMs + FAST_POLL_WINDOW_MS : undefined, }), - ); + // A reopened Conversation shows the cached copy at once, but it can miss + // messages that arrived while the reader was away. Fetch it each visit. + refetchOnMount: "always", + }); // Defer the whole server snapshot, not history alone. Queue removal and the // matching transcript must paint together while composer input stays urgent. const deferredDetail = useDeferredValue(detail.data); @@ -893,6 +913,11 @@ export function useConversationData(conversationId: string | undefined) { ? history.hasNextPage : Boolean(detailData?.previousCursor), isPending: detail.isPending, + /** The latest detail after a fetch in this visit, before it is deferred. */ + fetchedDetail: + detail.isFetchedAfterMount && !detail.isFetching + ? detail.data + : undefined, isLoadingPreviousPage, pendingAuthorization: mailbox?.authorization, pendingGeneratedAt: mailbox?.generatedAt, diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 3688054f0d..88235b7ced 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -22,6 +22,8 @@ import { const BOTTOM_PROXIMITY_PX = 96; const PREVIOUS_PAGE_PROXIMITY_PX = 600; const USER_SCROLL_DELTA_PX = 2; +// Leave a little earlier context above the "New" marker. +const UNREAD_START_OFFSET_PX = 48; const PIN_START_ATTRIBUTE = "data-transcript-pin-start"; const PIN_START_MARGIN_PX = 16; @@ -46,6 +48,7 @@ export type ScrollSnapshot = { type BottomPinResult = { anchorRef: RefObject; + contentElement: HTMLDivElement | null; contentRef: RefCallback; hasPendingUpdate: boolean; jumpToBottom: () => void; @@ -259,14 +262,19 @@ export function terminalReplyPinState(input: { /** * Show the jump control only when the reader left the bottom and a newer tail - * arrived. Staying pinned, or intentionally following, must not flash it. + * arrived, or when the transcript opened at unread rows. Staying pinned, or + * intentionally following, must not flash it. */ export function shouldShowJumpToLatest(input: { enabled: boolean; following: boolean; hasPendingUpdate: boolean; + openedAtUnread?: boolean; }): boolean { - return input.enabled && !input.following && input.hasPendingUpdate; + return ( + !input.following && + (Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate)) + ); } /** Resolve scroll intent with user upward movement taking precedence over bottom slack. */ @@ -314,6 +322,8 @@ export function usePinnedTranscriptBottom(input: { historyVersion: string; juniorMessageVersion: string; pinRequestVersion?: number; + /** Open at the "New" marker when the transcript has one. */ + startAtUnread?: boolean; view: TranscriptViewMode; versions: Record; }): BottomPinResult { @@ -337,6 +347,7 @@ export function usePinnedTranscriptBottom(input: { const seenPinStartsRef = useRef(new Set()); const [following, setFollowing] = useState(false); const [hasPendingUpdate, setHasPendingUpdate] = useState(false); + const [openedAtUnread, setOpenedAtUnread] = useState(false); const [contentElement, setContentElement] = useState( null, ); @@ -351,9 +362,14 @@ export function usePinnedTranscriptBottom(input: { if (!input.enabled) setHasPendingUpdate(false); }, [input.enabled]); + // The transcript opened at the latest message, but the reader prefers the + // "New" marker. The marker can appear when a fetch replaces a cached detail. + const awaitingUnreadMarkerRef = useRef(false); const setFollowingIntent = useCallback((value: boolean) => { followingRef.current = value; setFollowing(value); + if (value) setOpenedAtUnread(false); + else awaitingUnreadMarkerRef.current = false; }, []); const rememberPlace = useCallback(() => { @@ -502,6 +518,32 @@ export function usePinnedTranscriptBottom(input: { [setFollowingIntent], ); + /** Scroll to the "New" marker, if the transcript has one. */ + const openAtUnreadMarker = useCallback((): boolean => { + const content = contentElementRef.current; + const root = scrollRootFor(content); + const marker = content?.querySelector("[data-transcript-unread-marker]"); + if (!root || !marker) return false; + awaitingUnreadMarkerRef.current = false; + const offset = + marker.getBoundingClientRect().top - viewportBounds(root).top; + setScrollTop( + root, + Math.max( + 0, + scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX, + ), + ); + const snapshot = scrollSnapshot(root); + previousScrollTopRef.current = snapshot.scrollTop; + // Unread rows that fit above the bottom leave the reader at the latest. + const atBottom = isNearScrollBottom(snapshot); + setFollowingIntent(atBottom); + setOpenedAtUnread(!atBottom); + rememberPlace(); + return true; + }, [rememberPlace, setFollowingIntent]); + useBrowserLayoutEffect(() => { if (!contentElement || !input.conversationId) return; if (initializedConversationRef.current === input.conversationId) return; @@ -510,24 +552,40 @@ export function usePinnedTranscriptBottom(input: { if (!root) return; initializedConversationRef.current = input.conversationId; + setHasPendingUpdate(false); + if (input.startAtUnread && openAtUnreadMarker()) return; + setScrollTop(root, scrollSnapshot(root).scrollHeight); previousScrollTopRef.current = scrollSnapshot(root).scrollTop; - // Start every conversation at its latest message. Keep following until the - // reader scrolls up so late content and footer layout cannot expose stale - // space below the transcript. + // Without unread rows, start at the latest message. Keep following until + // the reader scrolls up so late content and footer layout cannot expose + // stale space below the transcript. setFollowingIntent(true); - setHasPendingUpdate(false); - }, [contentElement, input.conversationId, setFollowingIntent]); + awaitingUnreadMarkerRef.current = Boolean(input.startAtUnread); + }, [ + contentElement, + input.conversationId, + input.startAtUnread, + openAtUnreadMarker, + setFollowingIntent, + ]); + + // Follow the latest message, unless the "New" marker appeared before the + // reader moved. Then open at the marker, as if it was there at the start. + const followLatest = useCallback(() => { + if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return; + scrollToBottom("auto"); + }, [openAtUnreadMarker, scrollToBottom]); const syncAfterLayoutChange = useCallback(() => { if (followingRef.current) { - scrollToBottom("auto"); + followLatest(); return; } restorePlace(); measurePosition("measure"); - }, [measurePosition, restorePlace, scrollToBottom]); + }, [followLatest, measurePosition, restorePlace]); // An earlier page renders above the reader. Hold their place before paint; // a reader at the bottom stays at the latest message. @@ -568,7 +626,7 @@ export function usePinnedTranscriptBottom(input: { following: followingRef.current, }) ) { - scrollToBottom("auto"); + followLatest(); setHasPendingUpdate(false); return; } @@ -581,7 +639,7 @@ export function usePinnedTranscriptBottom(input: { input.view, input.versions.rich, input.versions.raw, - scrollToBottom, + followLatest, setFollowingIntent, ]); @@ -655,6 +713,7 @@ export function usePinnedTranscriptBottom(input: { }, [contentElement, syncAfterLayoutChange]); const jumpToBottom = useCallback(() => { + awaitingUnreadMarkerRef.current = false; setFollowingIntent(true); setHasPendingUpdate(false); scrollToBottom(preferredExplicitScrollBehavior()); @@ -698,6 +757,7 @@ export function usePinnedTranscriptBottom(input: { return useMemo( () => ({ anchorRef, + contentElement, contentRef, hasPendingUpdate, jumpToBottom, @@ -705,9 +765,18 @@ export function usePinnedTranscriptBottom(input: { enabled: input.enabled, following, hasPendingUpdate, + openedAtUnread, }), }), - [contentRef, following, hasPendingUpdate, input.enabled, jumpToBottom], + [ + contentElement, + contentRef, + following, + hasPendingUpdate, + input.enabled, + jumpToBottom, + openedAtUnread, + ], ); } @@ -744,7 +813,8 @@ export function useAutoLoadPreviousPage(input: { return setSentinel; } -function scrollRootFor(element: HTMLElement | null): ScrollRoot | null { +/** Find the nearest element that scrolls the transcript, or the window. */ +export function scrollRootFor(element: HTMLElement | null): ScrollRoot | null { if (typeof window === "undefined") return null; if (!element) return window; @@ -819,6 +889,7 @@ function setScrollTop( root.scrollTo({ behavior, top: scrollTop }); } +/** Read the visible band of a scroll root in client coordinates. */ function viewportBounds(root: ScrollRoot): { bottom: number; top: number } { if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 }; const top = root.getBoundingClientRect().top; diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts new file mode 100644 index 0000000000..06ec8a49bf --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -0,0 +1,267 @@ +/** + * Track how far the reader has read a Conversation transcript. + * + * Rows from stored events carry `data-transcript-seq`. While the page is + * visible, the reporter sends the sequence of the last row that has reached + * the viewport. The server keeps the highest value, so every client of the + * same user shares one position. Detail polls bring it back to other clients. + * + * The page reads the position once when a Conversation opens. That value + * places the "New" marker and, by the reader's preference, where the + * transcript starts. It does not move during the visit, like Slack. + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + conversationReadPositionSchema, + type ConversationDetailReport, + type ConversationReadPosition, +} from "@sentry/junior/api/schema"; +import { put } from "../http"; +import { conversationDetailQueryKey } from "./queries"; +import { scrollRootFor } from "./transcriptBottomPinning"; + +const READ_REPORT_DELAY_MS = 750; +const SEQ_ATTRIBUTE = "data-transcript-seq"; +const ROW_SELECTOR = `[${SEQ_ATTRIBUTE}]`; +const START_POSITION_STORAGE_KEY = "junior:conversation:start-position"; + +/** Where a Conversation opens: at the first unread row or at the latest. */ +export type ConversationStartPosition = "unread" | "latest"; + +/** + * Event sequences that were unread when the Conversation opened. Rows that + * arrive later are not in it, so a reader who follows new replies gets no + * marker for them. + */ +export type TranscriptUnreadRange = { afterSeq: number; throughSeq: number }; + +/** The read state when the Conversation opened. */ +export type ReadPositionAtOpen = { + lastReadSeq?: number; + unread?: TranscriptUnreadRange; +}; + +/** Edit the browser-only preference for where Conversations open. */ +export function useConversationStartPosition() { + return useLocalStorage( + START_POSITION_STORAGE_KEY, + "unread", + ); +} + +/** + * Read the start preference without subscribing. A Conversation reads it once + * when it opens, and this works where `useLocalStorage` cannot, such as + * server rendering. + */ +export function readConversationStartPosition(): ConversationStartPosition { + if (typeof window === "undefined") return "unread"; + try { + const stored = window.localStorage.getItem(START_POSITION_STORAGE_KEY); + return stored && JSON.parse(stored) === "latest" ? "latest" : "unread"; + } catch { + return "unread"; + } +} + +/** + * Keep the read state from when the reader opened the Conversation. The page + * is keyed by Conversation, so this runs once for each visit. + * + * The read position comes from the first detail, which can be a cached copy. + * The last unread row comes from the first detail fetched in this visit, so a + * stale cache cannot hide messages that arrived while the reader was away, + * and later polls cannot add rows that arrived after open. + */ +export function useReadPositionAtOpen( + detail: DetailAtOpen | undefined, + /** The first detail fetched in this visit, not only read from the cache. */ + fetched: DetailAtOpen | undefined, +): ReadPositionAtOpen { + // Store derived state during render so the first transcript render has it. + const [opened, setOpened] = useState<{ lastReadSeq?: number }>(); + const [fetchedEnd, setFetchedEnd] = useState<{ throughSeq?: number }>(); + const read = + opened ?? + (detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined); + if (!opened && read) setOpened(read); + const end = + fetchedEnd ?? + (fetched ? { throughSeq: fetched.events.at(-1)?.seq } : undefined); + if (!fetchedEnd && end) setFetchedEnd(end); + return readPositionAtOpen( + read?.lastReadSeq, + end ? end.throughSeq : detail?.events.at(-1)?.seq, + ); +} + +type DetailAtOpen = { + events: readonly { seq: number }[]; + readPosition?: ConversationReadPosition; +}; + +/** Find the rows after the read position, through the last row at open. */ +function readPositionAtOpen( + lastReadSeq: number | undefined, + throughSeq: number | undefined, +): ReadPositionAtOpen { + if (lastReadSeq === undefined) return {}; + if (throughSeq === undefined || throughSeq <= lastReadSeq) { + return { lastReadSeq }; + } + return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } }; +} + +/** + * Send read positions for one Conversation. A position at or before the one + * in the cached detail is not sent, because the server already has it. The + * cached detail keeps the stored value, so the next visit in this session + * opens at the right place. + */ +export function useMarkConversationRead(conversationId: string) { + const queryClient = useQueryClient(); + const { mutate } = useMutation({ + mutationFn: (lastReadSeq: number) => + put( + conversationReadPositionSchema, + `/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { lastReadSeq }, + ), + onSuccess: (readPosition) => { + queryClient.setQueryData( + conversationDetailQueryKey(conversationId), + (detail) => (detail ? { ...detail, readPosition } : detail), + ); + }, + }); + return useCallback( + (lastReadSeq: number) => { + const stored = queryClient.getQueryData( + conversationDetailQueryKey(conversationId), + )?.readPosition?.lastReadSeq; + if (stored !== undefined && lastReadSeq <= stored) return; + mutate(lastReadSeq); + }, + [conversationId, mutate, queryClient], + ); +} + +/** + * Report the last transcript row that the reader saw. + * + * An IntersectionObserver keeps the set of rows in the viewport, so a scroll + * does not measure rows. Reports send the highest seq in that set. They wait + * a short time and only move forward. A hidden or paused page does not + * report. Leaving the page or hiding it sends the pending row at once, so a + * quick exit keeps progress. + */ +export function useTranscriptReadReporter(input: { + content: HTMLElement | null; + /** The stored position when the Conversation opened. */ + lastReadSeq?: number; + onRead?: (seq: number) => void; + /** + * Stop reporting while the rows are not in reading order, such as search + * results. This is read when a report is scheduled, so the render that + * changes the rows also stops their report. + */ + paused?: boolean; +}): void { + const onReadRef = useRef(input.onRead); + onReadRef.current = input.onRead; + const pausedRef = useRef(input.paused); + pausedRef.current = input.paused; + const reportedRef = useRef(input.lastReadSeq ?? -1); + const enabled = Boolean(input.onRead); + + useEffect(() => { + const content = input.content; + const root = scrollRootFor(content); + if (!enabled || !content || !root) return; + if (typeof IntersectionObserver === "undefined") return; + + const visibleRows = new Set(); + let timer: number | undefined; + let seenSeq: number | null = null; + const send = () => { + if (timer !== undefined) window.clearTimeout(timer); + timer = undefined; + const seq = seenSeq; + seenSeq = null; + if (seq === null || seq <= reportedRef.current) return; + reportedRef.current = seq; + onReadRef.current?.(seq); + }; + const schedule = () => { + if (pausedRef.current || document.visibilityState !== "visible") return; + // Read the seq now: an activity group row moves its seq as it grows. + for (const row of visibleRows) { + const seq = Number(row.dataset.transcriptSeq); + if (Number.isSafeInteger(seq)) seenSeq = Math.max(seenSeq ?? -1, seq); + } + if (seenSeq === null) return; + timer ??= window.setTimeout(send, READ_REPORT_DELAY_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") schedule(); + else send(); + }; + + const intersections = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const row = entry.target as HTMLElement; + if (entry.isIntersecting && row.isConnected) visibleRows.add(row); + else visibleRows.delete(row); + } + schedule(); + }, + { root: root === window ? null : (root as HTMLElement) }, + ); + const rowsIn = (node: Node): HTMLElement[] => { + if (!(node instanceof HTMLElement)) return []; + const rows = [...node.querySelectorAll(ROW_SELECTOR)]; + return node.matches(ROW_SELECTOR) ? [node, ...rows] : rows; + }; + const observeRows = (node: Node) => { + for (const row of rowsIn(node)) intersections.observe(row); + }; + const forgetRows = (node: Node) => { + for (const row of rowsIn(node)) { + intersections.unobserve(row); + visibleRows.delete(row); + } + }; + // Rows mount and unmount as history loads and replies arrive. An + // activity group row keeps its node and raises its seq as tools arrive. + const mutations = new MutationObserver((records) => { + let seqChanged = false; + for (const record of records) { + record.addedNodes.forEach(observeRows); + record.removedNodes.forEach(forgetRows); + if (record.type === "attributes") { + const row = record.target as HTMLElement; + intersections.observe(row); + seqChanged ||= visibleRows.has(row); + } + } + if (seqChanged) schedule(); + }); + mutations.observe(content, { + attributeFilter: [SEQ_ATTRIBUTE], + childList: true, + subtree: true, + }); + observeRows(content); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + send(); + intersections.disconnect(); + mutations.disconnect(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [enabled, input.content]); +} diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index a2017e5aae..88612fc611 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -75,7 +75,7 @@ export type RenderedMessageEntry = { }; }; -export type RenderedTranscriptEntry = +export type RenderedTranscriptEntry = ( | RenderedAttachmentsDeliveredEntry | RenderedContextEventEntry | RenderedFailureEntry @@ -83,7 +83,11 @@ export type RenderedTranscriptEntry = | RenderedStructuredEventEntry | RenderedReasoningEntry | RenderedSubagentEntry - | RenderedToolEntry; + | RenderedToolEntry +) & { + /** Event sequence of the source message. Pending sends have none. */ + seq?: number; +}; export type TranscriptViewMode = "raw" | "rich"; @@ -94,6 +98,8 @@ export function groupTranscriptMessages( const entries: RenderedTranscriptEntry[] = []; for (const message of messages) { + const seq = message.pending ? undefined : message.sourceSeq; + const source = seq === undefined ? {} : { seq }; let textParts: TranscriptViewTextPart[] = []; let textGroup = 0; const flushMessage = () => { @@ -108,6 +114,7 @@ export function groupTranscriptMessages( key: `${message.sourceSeq}:message:${textGroup}`, kind: "message", message: nextMessage as RenderedMessageEntry["message"], + ...source, }); textParts = []; textGroup += 1; @@ -124,6 +131,7 @@ export function groupTranscriptMessages( entries.push({ key: `tool:${part.id}`, kind: "tool", + ...source, part, timestamp: part.startedTimestamp ?? message.timestamp, }); @@ -131,6 +139,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:reasoning:${partIndex}`, kind: "reasoning", + ...source, part, timestamp: message.timestamp, }); @@ -138,6 +147,7 @@ export function groupTranscriptMessages( entries.push({ key: `subagent:${part.id}`, kind: "subagent", + ...source, part, timestamp: message.timestamp, }); @@ -145,6 +155,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:structured-event:${part.namespace}:${part.name}`, kind: "structured_event", + ...source, part, timestamp: message.timestamp, }); @@ -152,6 +163,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:attachments-delivered`, kind: "attachments_delivered", + ...source, part, timestamp: message.timestamp, }); @@ -159,6 +171,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:context:${partIndex}`, kind: "context", + ...source, part, timestamp: message.timestamp, }); @@ -170,6 +183,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:failure`, kind: "failure", + ...source, failureCode: message.failureCode, ...(message.failureReason ? { failureReason: message.failureReason } diff --git a/packages/junior-dashboard/src/client/pages/settings/SettingsPage.tsx b/packages/junior-dashboard/src/client/pages/settings/SettingsPage.tsx index 1bf00415e0..ff90f590f1 100644 --- a/packages/junior-dashboard/src/client/pages/settings/SettingsPage.tsx +++ b/packages/junior-dashboard/src/client/pages/settings/SettingsPage.tsx @@ -13,6 +13,11 @@ import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { Textarea } from "../../components/ui/textarea"; import { Field, FieldDescription, FieldLabel } from "../../components/ui/field"; +import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group"; +import { + useConversationStartPosition, + type ConversationStartPosition, +} from "../../conversations/transcriptReadPosition"; import { InlineError } from "../../components/InlineError"; import { Card } from "../../components/layout/Card"; import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http"; @@ -99,11 +104,68 @@ export function SettingsPage({ identity }: SettingsPageProps) {
+ ); } +const startPositionOptions: { + description: string; + label: string; + value: ConversationStartPosition; +}[] = [ + { + description: "Open at the first new message, below a “New” line.", + label: "Where I left off", + value: "unread", + }, + { + description: "Always open at the newest message.", + label: "Latest message", + value: "latest", + }, +]; + +/** Let the user choose where a Conversation opens on this browser. */ +function ConversationStartSetting() { + const [startPosition, setStartPosition] = useConversationStartPosition(); + return ( + +
+ + Open conversations at + +

+ Junior keeps your place in each conversation on all your devices. This + choice applies to this browser. +

+ + aria-label="Open conversations at" + className="grid gap-3" + onValueChange={setStartPosition} + value={startPosition} + > + {startPositionOptions.map((option) => ( + + ))} + +
+
+ ); +} + /** * Let the user save text that changes how the agent replies to them. The * server returns 404 when the app does not enable this experiment. diff --git a/packages/junior-dashboard/src/mock-reporting/read-positions.ts b/packages/junior-dashboard/src/mock-reporting/read-positions.ts new file mode 100644 index 0000000000..1cfd3cf2b9 --- /dev/null +++ b/packages/junior-dashboard/src/mock-reporting/read-positions.ts @@ -0,0 +1,39 @@ +import type { + ConversationDetailReport, + ConversationReadPosition, +} from "@sentry/junior/api/schema"; +import { readMockConversationDetail } from "./fixtures"; + +/** Process-local read positions so mock QA can reopen at unread rows. */ +const mockReadPositions = new Map(); + +/** Clear mock read positions so tests sharing a worker do not leak state. */ +export function resetMockReadPositions(): void { + mockReadPositions.clear(); +} + +/** Move a mock read position forward, like the API. */ +export function markMockConversationRead( + conversationId: string, + lastReadSeq: number, +): ConversationReadPosition | undefined { + const detail = readMockConversationDetail(conversationId); + if (!detail) return undefined; + const lastSeq = detail.events.at(-1)?.seq ?? 0; + const stored = Math.max( + mockReadPositions.get(conversationId) ?? -1, + Math.min(lastReadSeq, lastSeq), + ); + mockReadPositions.set(conversationId, stored); + return { lastReadSeq: stored }; +} + +/** Add the mock read position to a detail report. */ +export function withMockReadPosition( + report: ConversationDetailReport, +): ConversationDetailReport { + const lastReadSeq = mockReadPositions.get(report.conversationId); + return lastReadSeq === undefined + ? report + : { ...report, readPosition: { lastReadSeq } }; +} diff --git a/packages/junior-dashboard/src/mock-reporting/routes.ts b/packages/junior-dashboard/src/mock-reporting/routes.ts index e719ff2e09..29d00f47fc 100644 --- a/packages/junior-dashboard/src/mock-reporting/routes.ts +++ b/packages/junior-dashboard/src/mock-reporting/routes.ts @@ -15,6 +15,7 @@ import { conversationFeedQuerySchema, conversationFeedSchema, conversationParamsSchema, + conversationReadPositionSchema, conversationPendingMessagesReportSchema, conversationStatsReportSchema, conversationWatchesReportSchema, @@ -38,6 +39,10 @@ import { automationRunListSchema, } from "@sentry/junior/api/schema"; import { mockChartPng } from "./chart-png"; +import { + markMockConversationRead, + withMockReadPosition, +} from "./read-positions"; import { filterMockConversationsByRepository, readMockCodeRepository, @@ -244,7 +249,27 @@ export function createMockReportingApi(): Hono<{ const { conversationId } = params.data; const report = readMockConversationDetail(conversationId, query.data.limit); return report - ? jsonResponse(conversationDetailReportSchema, report) + ? jsonResponse( + conversationDetailReportSchema, + withMockReadPosition(report), + ) + : errorResponse("Conversation not found.", 404); + }); + app.put("/conversations/:conversationId/read-position", async (c) => { + const params = conversationParamsSchema.safeParse(c.req.param()); + if (!params.success) { + return errorResponse("Invalid route parameters.", 400); + } + const body = conversationReadPositionSchema.safeParse(await c.req.json()); + if (!body.success) { + return errorResponse("Invalid request body.", 400); + } + const result = markMockConversationRead( + params.data.conversationId, + body.data.lastReadSeq, + ); + return result + ? jsonResponse(conversationReadPositionSchema, result) : errorResponse("Conversation not found.", 404); }); app.patch("/conversations/:conversationId/archive", async (c) => { diff --git a/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts b/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts index f8602e22a8..54b7739f4d 100644 --- a/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts @@ -54,6 +54,7 @@ describe("transcript render grouping", () => { key: "42:failure", kind: "failure", failureCode: "model_execution_failed", + seq: 42, timestamp: 1_000, }, ]); diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index 2b2bbaff0d..14bb15b2b3 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -174,6 +174,7 @@ describe("canonical event transcript reduction", () => { key: "1:reasoning:0", kind: "reasoning", part: { type: "reasoning", text: "Inspect the inputs." }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:01.000Z"), }, { @@ -186,12 +187,14 @@ describe("canonical event transcript reduction", () => { startedTimestamp: Date.parse("2026-01-01T00:00:00.000Z"), status: "running", }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:00.000Z"), }, { key: "1:reasoning:2", kind: "reasoning", part: { type: "reasoning", text: "Check the result." }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:01.000Z"), }, ]); diff --git a/packages/junior/migrations/0072_conversation_read_positions.sql b/packages/junior/migrations/0072_conversation_read_positions.sql new file mode 100644 index 0000000000..81e301b081 --- /dev/null +++ b/packages/junior/migrations/0072_conversation_read_positions.sql @@ -0,0 +1,10 @@ +CREATE TABLE "junior_conversation_read_positions" ( + "user_id" text NOT NULL, + "conversation_id" text NOT NULL, + "last_read_seq" integer NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "junior_conversation_read_positions_user_conversation_pk" PRIMARY KEY("user_id","conversation_id") +); +--> statement-breakpoint +ALTER TABLE "junior_conversation_read_positions" ADD CONSTRAINT "junior_conversation_read_positions_user_id_junior_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."junior_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_conversation_read_positions" ADD CONSTRAINT "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0072_snapshot.json b/packages/junior/migrations/meta/0072_snapshot.json new file mode 100644 index 0000000000..4aaf3bbe6d --- /dev/null +++ b/packages/junior/migrations/meta/0072_snapshot.json @@ -0,0 +1,4064 @@ +{ + "id": "c894a9e6-7ba9-4fc6-8d22-c95f15ccb156", + "prevId": "e195a34f-e236-4221-881e-ccdcee261e6e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_plugin_report_idx": { + "name": "junior_conversation_events_plugin_report_idx", + "columns": [ + { + "expression": "(\"payload\"->>'namespace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'name')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'structured_event'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_read_positions": { + "name": "junior_conversation_read_positions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_seq": { + "name": "last_read_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_read_positions_user_id_junior_users_id_fk": { + "name": "junior_conversation_read_positions_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_read_positions_user_conversation_pk": { + "name": "junior_conversation_read_positions_user_conversation_pk", + "columns": ["user_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_assignees": { + "name": "junior_review_item_assignees", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_assignees_user_idx": { + "name": "junior_review_item_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_assignees_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_assignees_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_assignees_user_id_junior_users_id_fk": { + "name": "junior_review_item_assignees_user_id_junior_users_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_assignees_item_id_user_id_pk": { + "name": "junior_review_item_assignees_item_id_user_id_pk", + "columns": ["item_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_conversations": { + "name": "junior_review_item_conversations", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_conversations_conversation_idx": { + "name": "junior_review_item_conversations_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_conversations_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_conversations_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_conversations_item_id_conversation_id_pk": { + "name": "junior_review_item_conversations_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_slack_cards": { + "name": "junior_review_item_slack_cards", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_review_item_slack_cards_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_slack_cards_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_slack_cards_item_id_conversation_id_pk": { + "name": "junior_review_item_slack_cards_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_items": { + "name": "junior_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assigned_to_everyone": { + "name": "assigned_to_everyone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_title": { + "name": "subject_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_url": { + "name": "subject_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by_conversation_id": { + "name": "added_by_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_turn_id": { + "name": "added_by_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_items_open_dedupe_uidx": { + "name": "junior_review_items_open_dedupe_uidx", + "columns": [ + { + "expression": "coalesce(\"provider\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"provider_tenant_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_review_items\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_status_idx": { + "name": "junior_review_items_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_subject_idx": { + "name": "junior_review_items_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_automation_idx": { + "name": "junior_review_items_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_conversations", + "columnsFrom": ["added_by_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_items_automation_id_junior_automations_id_fk": { + "name": "junior_review_items_automation_id_junior_automations_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_review_items_closed_by_user_id_junior_users_id_fk": { + "name": "junior_review_items_closed_by_user_id_junior_users_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_users", + "columnsFrom": ["closed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_review_items_kind_check": { + "name": "junior_review_items_kind_check", + "value": "(\"junior_review_items\".\"kind\" = 'notice' and \"junior_review_items\".\"request\" is null and \"junior_review_items\".\"answer\" is null) or (\"junior_review_items\".\"kind\" = 'action' and \"junior_review_items\".\"request\" is not null)" + }, + "junior_review_items_status_check": { + "name": "junior_review_items_status_check", + "value": "(\"junior_review_items\".\"status\" = 'open' and \"junior_review_items\".\"closed_at\" is null) or (\"junior_review_items\".\"status\" in ('done', 'dismissed') and \"junior_review_items\".\"closed_at\" is not null)" + }, + "junior_review_items_tenant_check": { + "name": "junior_review_items_tenant_check", + "value": "(\"junior_review_items\".\"provider\" is null) = (\"junior_review_items\".\"provider_tenant_id\" is null)" + }, + "junior_review_items_visibility_check": { + "name": "junior_review_items_visibility_check", + "value": "\"junior_review_items\".\"visibility\" = 'public' or not \"junior_review_items\".\"assigned_to_everyone\"" + } + }, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customization": { + "name": "customization", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "vcpus": { + "name": "vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index d0bcc41e3e..322c08b994 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -505,6 +505,13 @@ "when": 1793640471869, "tag": "0071_review_item_turn", "breakpoints": true + }, + { + "idx": 72, + "version": "7", + "when": 1793657651066, + "tag": "0072_conversation_read_positions", + "breakpoints": true } ] } diff --git a/packages/junior/src/api/conversations/detail.ts b/packages/junior/src/api/conversations/detail.ts index e017cf7185..a218f271dc 100644 --- a/packages/junior/src/api/conversations/detail.ts +++ b/packages/junior/src/api/conversations/detail.ts @@ -33,6 +33,7 @@ import { readConversationArchivedAt } from "./archive"; import { readConversationParticipants } from "./participants"; import { readConversationForks } from "./fork"; import { readConversationAgentTree } from "./agent-tree"; +import { readConversationReadPosition } from "./read-position"; /** Project stored metadata and a bounded event page into a signed history cursor. */ function projectConversationDetail(args: { @@ -237,13 +238,17 @@ export async function readConversationDetail( limit: options.limit ?? 500, }); if (!report) return undefined; - const [forks, agentTree] = await Promise.all([ + const [forks, agentTree, readPosition] = await Promise.all([ readConversationForks(conversationId, options.viewer), readConversationAgentTree(conversationId, options.viewer), + options.viewer + ? readConversationReadPosition(options.viewer, conversationId) + : undefined, ]); return conversationDetailReportSchema.parse({ ...report, ...forks, ...(agentTree ? { agentTree } : undefined), + ...(readPosition ? { readPosition } : undefined), }); } diff --git a/packages/junior/src/api/conversations/read-position.ts b/packages/junior/src/api/conversations/read-position.ts new file mode 100644 index 0000000000..7622ee3dcb --- /dev/null +++ b/packages/junior/src/api/conversations/read-position.ts @@ -0,0 +1,77 @@ +import type { User } from "@sentry/junior-plugin-api"; +import { and, eq, max, sql } from "drizzle-orm"; +import { getDb } from "@/chat/db"; +import { + juniorConversationEvents, + juniorConversationReadPositions, + juniorConversations, +} from "@/db/schema"; +import { throwApiError } from "../http"; +import type { ConversationReadPosition } from "../schema/conversation-read-position"; + +/** Read how far the viewer has read one Conversation, if they have read it. */ +export async function readConversationReadPosition( + viewer: User, + conversationId: string, +): Promise { + const [row] = await getDb() + .select({ lastReadSeq: juniorConversationReadPositions.lastReadSeq }) + .from(juniorConversationReadPositions) + .where( + and( + eq(juniorConversationReadPositions.userId, viewer.id), + eq(juniorConversationReadPositions.conversationId, conversationId), + ), + ) + .limit(1); + return row; +} + +/** + * Move the viewer's read position forward. + * + * The stored value never goes back, so devices can report in any order. A + * value past the last event is cut to the last event, so a bad client cannot + * hide messages that arrive later. + */ +export async function markConversationRead( + viewer: User, + conversationId: string, + body: ConversationReadPosition, +): Promise { + const db = getDb(); + const [conversation] = await db + .select({ lastSeq: max(juniorConversationEvents.seq) }) + .from(juniorConversations) + .leftJoin( + juniorConversationEvents, + eq( + juniorConversationEvents.conversationId, + juniorConversations.conversationId, + ), + ) + .where(eq(juniorConversations.conversationId, conversationId)) + .groupBy(juniorConversations.conversationId); + if (!conversation) throwApiError(404, "Conversation not found."); + + const [row] = await db + .insert(juniorConversationReadPositions) + .values({ + conversationId, + lastReadSeq: Math.min(body.lastReadSeq, conversation.lastSeq ?? 0), + updatedAt: new Date(), + userId: viewer.id, + }) + .onConflictDoUpdate({ + target: [ + juniorConversationReadPositions.userId, + juniorConversationReadPositions.conversationId, + ], + set: { + lastReadSeq: sql`greatest(${juniorConversationReadPositions.lastReadSeq}, excluded.last_read_seq)`, + updatedAt: sql`excluded.updated_at`, + }, + }) + .returning({ lastReadSeq: juniorConversationReadPositions.lastReadSeq }); + return row!; +} diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index 175dfb5544..00b5287be4 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -24,6 +24,7 @@ import { createConversationBodySchema, createConversationMessageBodySchema, } from "../schema/conversation"; +import { conversationReadPositionSchema } from "../schema/conversation-read-position"; import { conversationStatsReportSchema } from "../schema/conversation-stats"; import { stopConversationTurnResponseSchema } from "../schema/conversation-stop"; import { conversationChildAgentsReportSchema } from "../schema/child-agent"; @@ -45,6 +46,7 @@ import { readConversationDetail } from "./detail"; import { forkConversationForViewer } from "./fork"; import { readConversationEvents } from "./event-list"; import { readConversationFeed } from "./list"; +import { markConversationRead } from "./read-position"; import { cancelConversationPendingMessagesForViewer } from "./cancel-pending-messages"; import { steerConversationPendingMessagesForViewer } from "./steer-pending-messages"; import { requireConversationPendingMessages } from "./pending-messages"; @@ -208,6 +210,30 @@ export function createConversationRoutes( }, ); + app.put( + "/:conversationId/read-position", + requireViewer, + validateRequest( + "param", + conversationParamsSchema, + "Invalid route parameters.", + ), + validateRequest( + "json", + conversationReadPositionSchema, + "Invalid request body.", + ), + async (context) => { + const viewer = context.get("viewer"); + const { conversationId } = context.req.valid("param"); + const body = context.req.valid("json"); + return jsonResponse( + conversationReadPositionSchema, + await markConversationRead(viewer, conversationId, body), + ); + }, + ); + app.get( "/:conversationId/events", validateRequest( diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 2568e4bb19..77ba90d516 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -87,6 +87,8 @@ export type { } from "./schema/conversation-stats"; export { stopConversationTurnResponseSchema } from "./schema/conversation-stop"; export type { StopConversationTurnResponse } from "./schema/conversation-stop"; +export { conversationReadPositionSchema } from "./schema/conversation-read-position"; +export type { ConversationReadPosition } from "./schema/conversation-read-position"; export type { AcceptedConversationMessage, ArchiveConversationBody, diff --git a/packages/junior/src/api/schema/conversation-read-position.ts b/packages/junior/src/api/schema/conversation-read-position.ts new file mode 100644 index 0000000000..92abf31292 --- /dev/null +++ b/packages/junior/src/api/schema/conversation-read-position.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * How far the signed-in viewer has read a Conversation transcript. + * + * `lastReadSeq` is the event sequence of the last transcript row the viewer + * saw. The server keeps the highest value it receives, so it only moves + * forward. + */ +export const conversationReadPositionSchema = z + .object({ lastReadSeq: z.number().int().nonnegative() }) + .strict(); + +export type ConversationReadPosition = z.infer< + typeof conversationReadPositionSchema +>; diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index dd86936a36..5a30c7b83a 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -6,6 +6,7 @@ import { } from "@/chat/attachments/input"; import { messageCardRefSchema } from "@/chat/conversations/cards"; import { z } from "zod"; +import { conversationReadPositionSchema } from "./conversation-read-position"; import { objectTypeSchema } from "@sentry/junior-plugin-api"; import { conversationTurnFailureCodeSchema, @@ -837,6 +838,8 @@ export const conversationDetailReportSchema = conversationSummaryReportSchema agentTree: z.array(conversationAgentTreeNodeSchema).min(2).optional(), /** The viewer can fork this conversation from an assistant reply. */ canFork: z.boolean().optional(), + /** How far the signed-in viewer has read. Absent until they read. */ + readPosition: conversationReadPositionSchema.optional(), forkedFromConversationId: z.string().optional(), /** Title of the fork source, when the viewer can open it. */ forkedFromTitle: z.string().optional(), diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 045023ea66..5810bcaf3c 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -8,6 +8,7 @@ import { juniorConversationMetrics } from "./schema/conversation-metrics"; import { juniorConversationBriefs } from "./schema/conversation-briefs"; import { juniorConversationBindings } from "./schema/conversation-bindings"; import { juniorConversationParticipants } from "./schema/conversation-participants"; +import { juniorConversationReadPositions } from "./schema/conversation-read-positions"; import { juniorConversations } from "./schema/conversations"; import { juniorAgentBindings, @@ -44,6 +45,7 @@ export { juniorConversationBriefs, juniorConversationBindings, juniorConversationParticipants, + juniorConversationReadPositions, juniorConversations, juniorLocationConfigurations, juniorDestinations, @@ -76,6 +78,7 @@ export const juniorSqlSchema = { juniorConversationBriefs, juniorConversationBindings, juniorConversationParticipants, + juniorConversationReadPositions, juniorConversations, juniorLocationConfigurations, juniorDestinations, diff --git a/packages/junior/src/db/schema/conversation-read-positions.ts b/packages/junior/src/db/schema/conversation-read-positions.ts new file mode 100644 index 0000000000..7eaa98c3c6 --- /dev/null +++ b/packages/junior/src/db/schema/conversation-read-positions.ts @@ -0,0 +1,31 @@ +import { integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core"; +import { juniorConversations } from "./conversations"; +import { juniorUsers } from "./users"; +import { timestamptz } from "./timestamps"; + +/** + * How far one signed-in user has read one Conversation transcript. + * + * `last_read_seq` is the event sequence of the last transcript row the user + * saw. It only moves forward, so clients on several devices can write in any + * order. + */ +export const juniorConversationReadPositions = pgTable( + "junior_conversation_read_positions", + { + userId: text("user_id") + .notNull() + .references(() => juniorUsers.id), + conversationId: text("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + lastReadSeq: integer("last_read_seq").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + primaryKey({ + name: "junior_conversation_read_positions_user_conversation_pk", + columns: [table.userId, table.conversationId], + }), + ], +); diff --git a/packages/junior/tests/integration/api/conversations/read-position.test.ts b/packages/junior/tests/integration/api/conversations/read-position.test.ts new file mode 100644 index 0000000000..15a660b177 --- /dev/null +++ b/packages/junior/tests/integration/api/conversations/read-position.test.ts @@ -0,0 +1,132 @@ +import { Hono } from "hono"; +import { afterEach, describe, expect, it } from "vitest"; +import { createJuniorApi } from "@/api"; +import { readConversationDetail } from "@/api/conversations/detail"; +import type { JuniorApiEnv } from "@/api/route"; +import { + conversationDetailReportSchema, + conversationReadPositionSchema, +} from "@/api/schema"; +import { closeDb, getConversationStore, getDb } from "@/chat/db"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { juniorConversationEvents } from "@/db/schema"; + +async function authenticatedApi(email: string) { + const viewer = await resolveViewerUser(email); + if (!viewer) throw new Error(`missing viewer for ${email}`); + const app = new Hono(); + app.use("*", async (context, next) => { + context.set("viewer", viewer); + await next(); + }); + app.route("/", createJuniorApi()); + return { app, viewer }; +} + +async function seedConversation(conversationId: string, eventCount: number) { + await getConversationStore().recordActivity({ + destination: { platform: "local" as const, conversationId }, + conversationId, + nowMs: 1_000, + source: "internal", + title: "Read position", + }); + await getDb() + .insert(juniorConversationEvents) + .values( + Array.from({ length: eventCount }, (_, seq) => ({ + conversationId, + createdAt: new Date(1_000 + seq), + historyVersion: 0, + payload: { + content: `Message ${seq}`, + provenance: { + authority: "instruction", + actor: { name: "event", platform: "system" }, + }, + timestamp: 1_000 + seq, + }, + schemaVersion: 1, + seq, + type: "user_message", + })), + ); +} + +function putReadPosition( + app: Hono, + conversationId: string, + lastReadSeq: number, +) { + return app.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { + body: JSON.stringify({ lastReadSeq }), + headers: { "content-type": "application/json" }, + method: "PUT", + }, + ); +} + +describe("conversation read position API", () => { + afterEach(async () => { + await closeDb(); + }); + + it("keeps one forward-only read position per viewer and shows it on detail", async () => { + const conversationId = "local:test:read-position"; + await seedConversation(conversationId, 5); + const { app, viewer } = await authenticatedApi("reader@example.com"); + const { viewer: otherViewer } = await authenticatedApi( + "other-reader@example.com", + ); + + const first = await putReadPosition(app, conversationId, 2); + expect(first.status).toBe(200); + expect(conversationReadPositionSchema.parse(await first.json())).toEqual({ + lastReadSeq: 2, + }); + + // Another device that reports an older place does not move it back. + const older = await putReadPosition(app, conversationId, 1); + expect(conversationReadPositionSchema.parse(await older.json())).toEqual({ + lastReadSeq: 2, + }); + + // A place after the last event stops at the last event. + const ahead = await putReadPosition(app, conversationId, 99); + expect(conversationReadPositionSchema.parse(await ahead.json())).toEqual({ + lastReadSeq: 4, + }); + + expect( + conversationDetailReportSchema.parse( + await readConversationDetail(conversationId, { viewer }), + ).readPosition, + ).toEqual({ lastReadSeq: 4 }); + expect( + conversationDetailReportSchema.parse( + await readConversationDetail(conversationId, { viewer: otherViewer }), + ).readPosition, + ).toBeUndefined(); + }); + + it("requires a viewer and an existing conversation", async () => { + const conversationId = "local:test:read-position-missing"; + await seedConversation(conversationId, 1); + + const unauthenticated = await createJuniorApi().request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { + body: JSON.stringify({ lastReadSeq: 0 }), + headers: { "content-type": "application/json" }, + method: "PUT", + }, + ); + expect(unauthenticated.status).toBe(401); + + const { app } = await authenticatedApi("reader@example.com"); + const missing = await putReadPosition(app, "local:test:no-such", 0); + expect(missing.status).toBe(404); + }); +});