feat(lens): page job reviews by index cursor

Adds api.reviews for GET /lens/{id}/runs/{job}/reviews?after=N, with a demo implementation. appendPage adds pages in arrival order and keeps the latest 200. liveJob now keys off reviewed, since the list no longer carries reviews.
This commit is contained in:
Ishaan Jaff 2026-10-03 17:40:30 -07:00
parent 73ad24f7de
commit db0e4df4f5
No known key found for this signature in database
4 changed files with 59 additions and 3 deletions

View file

@ -2,7 +2,7 @@ import { z } from "zod";
import type { RunWindow } from "../investigations/detail/RunNowDialog";
import type { ApiClient } from "@/lib/http/client";
import type { components } from "@/lib/http/schema";
import type { ActivitySelection, Job, Lens, LensList, Sample, Settings, WorkerCreated } from "../model/types";
import type { ActivitySelection, Job, Lens, LensList, Review, Sample, Settings, WorkerCreated } from "../model/types";
import type { AnalysisModelInfo } from "../setup/fields/analysisModels";
export type ExecutionContent = components["schemas"]["ExecutionContent"];
@ -25,6 +25,11 @@ export type Key = z.infer<typeof keySchema>;
export type KeyPage = z.infer<typeof keyPageSchema>;
export type KeyInfo = z.infer<typeof keyInfoSchema>["info"];
export interface ReviewPage {
readonly reviews: readonly Review[];
readonly reviewed: number;
}
export interface AnalysisKeyRequest {
readonly model: string;
readonly budget: number;
@ -37,6 +42,7 @@ export interface LensApi {
activity(): Promise<{ traces: boolean; requests: boolean }>;
runs(lensId: string, offset: number): Promise<Job[]>;
run(lensId: string, jobId: string): Promise<Job>;
reviews(lensId: string, jobId: string, after: number): Promise<ReviewPage>;
execution(lensId: string, executionId: string, offset: number): Promise<ExecutionContent>;
sample(selection: ActivitySelection, offset: number, asOf: string): Promise<Sample>;
agents(): Promise<string[]>;
@ -64,6 +70,8 @@ export function liveLensApi(apiClient: ApiClient, accessToken: string): LensApi
activity: () => apiClient.get("/lens/activity/available", { accessToken }),
runs: (lensId, offset) => apiClient.get<Job[]>(`/lens/${lensId}/runs`, { accessToken, query: { offset } }),
run: (lensId, jobId) => apiClient.get<Job>(`/lens/${lensId}/runs/${jobId}`, { accessToken }),
reviews: (lensId, jobId, after) =>
apiClient.get<ReviewPage>(`/lens/${lensId}/runs/${jobId}/reviews`, { accessToken, query: { after } }),
execution: (lensId, executionId, offset) =>
apiClient.get<ExecutionContent>(`/lens/${lensId}/executions/${encode(executionId)}`, {
accessToken,

View file

@ -354,6 +354,10 @@ function demoLensApi(data: LensDemoData): LensApi {
activity: async () => ({ traces: true, requests: false }),
runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)),
run: (lensId, jobId) => found(jobs(lensId)?.find((job) => job.id === jobId)),
reviews: async (lensId, jobId, after) => {
const job = await found(jobs(lensId)?.find((item) => item.id === jobId));
return { reviews: job.reviews.slice(Math.max(0, after - (job.reviewed - job.reviews.length))), reviewed: job.reviewed };
},
execution: notInDemo,
sample: notInDemo,
agents: notInDemo,

View file

@ -8,6 +8,8 @@ import {
inGroup,
share,
issueCount,
appendPage,
EMPTY_FEED,
shortVerdict,
stripState,
tickerLine,
@ -262,7 +264,8 @@ describe("playback queue", () => {
});
describe("which job the live run shows", () => {
const job = (id: string, status: Job["status"], reviews: Review[]) => ({ id, status, reviews }) as unknown as Job;
const job = (id: string, status: Job["status"], reviews: Review[]) =>
({ id, status, reviews: [], reviewed: reviews.length }) as unknown as Job;
it("shows the active job once it has reviews", () => {
expect(liveJob([job("new", "running", [review("a")]), job("old", "completed", [review("b")])])?.id).toBe("new");
@ -359,6 +362,32 @@ describe("drawer focus", () => {
});
});
describe("incremental reviews", () => {
const at = (id: string, minute: number) => review(id, { at: `2026-10-03T16:${String(minute).padStart(2, "0")}:00Z` });
it("appends pages in arrival order, not by time, and advances the cursor to the reviewed count", () => {
const first = appendPage(EMPTY_FEED, { reviews: [at("a", 5), at("b", 2)], reviewed: 2 });
expect(first).toEqual({ reviews: [at("a", 5), at("b", 2)], cursor: 2 });
const second = appendPage(first, { reviews: [at("c", 1)], reviewed: 3 });
expect(second.reviews.map((r) => r.execution_id)).toEqual(["a", "b", "c"]);
expect(second.cursor).toBe(3);
});
it("keeps the same feed for an empty page and never duplicates a re-sent review", () => {
const feed = appendPage(EMPTY_FEED, { reviews: [at("a", 1)], reviewed: 1 });
expect(appendPage(feed, { reviews: [], reviewed: 1 })).toBe(feed);
expect(appendPage(feed, { reviews: [at("a", 1)], reviewed: 1 }).reviews).toHaveLength(1);
});
it("caps how many reviews are kept, dropping the oldest", () => {
const many = Array.from({ length: 250 }, (_, n) => review(`r${n}`));
const feed = appendPage(EMPTY_FEED, { reviews: many, reviewed: 250 });
expect(feed.reviews).toHaveLength(200);
expect(feed.reviews[0].execution_id).toBe("r50");
expect(feed.cursor).toBe(250);
});
});
describe("time left reading", () => {
const started = {
created_at: "2026-10-03T16:00:00Z",

View file

@ -35,7 +35,7 @@ export function liveJob(jobs: readonly Job[]): Job | undefined {
const active = jobs.find((job) => job.status === "queued" || job.status === "running");
if (active) return active;
const latest = jobs[0];
return latest?.reviews.length ? latest : undefined;
return latest && latest.reviewed > 0 ? latest : undefined;
}
export function reviewKey(review: Pick<Review, "execution_id" | "at">): string {
@ -116,6 +116,21 @@ export function verdictLine(review: Pick<Review, "cannot_assess" | "verdicts">):
return review.verdicts[0]?.summary ?? "No issue observed";
}
const KEPT_REVIEWS = 200;
export interface ReviewFeed {
reviews: readonly Review[];
cursor: number;
}
export const EMPTY_FEED: ReviewFeed = { reviews: [], cursor: 0 };
export function appendPage(feed: ReviewFeed, page: { reviews: readonly Review[]; reviewed: number }): ReviewFeed {
if (page.reviewed === feed.cursor && !page.reviews.length) return feed;
const added = unseen(page.reviews, new Set(feed.reviews.map(reviewKey)));
return { reviews: [...feed.reviews, ...added].slice(-KEPT_REVIEWS), cursor: page.reviewed };
}
export function unseen(reviews: readonly Review[], seen: ReadonlySet<string>): Review[] {
return reviews.filter((review) => !seen.has(reviewKey(review)));
}