mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
fix: preserve accurate history boundaries in run views
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b197da6e60
commit
8859f37fa2
7 files changed
+68
-29
No files matched your search
@@ -484,7 +484,7 @@ describe("agent live run routes", () => {
|
||||
beforeSeq: undefined,
|
||||
limit: 200,
|
||||
});
|
||||
expect(forward.body).toEqual([{ seq: 11, eventType: "stdout", payload: null, historyAfter: false }]);
|
||||
expect(forward.body).toEqual([{ seq: 11, eventType: "stdout", payload: null, historyBefore: false, historyAfter: false }]);
|
||||
|
||||
for (const query of ["afterSeq=-1", "afterSeq=1.5", "afterSeq=tail&beforeSeq=-1", "limit=1001"]) {
|
||||
const invalid = await requestApp(app, (url) =>
|
||||
|
||||
@@ -48,5 +48,7 @@ describe("bounded heartbeat run event pages", () => {
|
||||
});
|
||||
expect(page).toHaveLength(1);
|
||||
expect(page[0].seq).toBe(1);
|
||||
expect(page[0].historyBefore).toBe(false);
|
||||
expect(page[0].historyAfter).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export function boundHeartbeatRunEventPage<T extends { seq: number }>(input: {
|
||||
const after = baseAfter || (byteCut && input.direction === "forward");
|
||||
return events.map((event, index) => ({
|
||||
...event,
|
||||
...(before && index === 0 ? { historyBefore: true as const } : {}),
|
||||
...(index === 0 ? { historyBefore: before } : {}),
|
||||
...(index === events.length - 1 ? { historyAfter: after } : {}),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ test("long native history opens at the latest activity and older events remain b
|
||||
await page.getByRole("button", { name: /Earlier activity/ }).last().click();
|
||||
await page.getByRole("link", { name: "View run", exact: true }).last().click();
|
||||
await expect(page).toHaveURL(new RegExp(`/runs/${run.id}$`));
|
||||
await expect(page.getByTestId("run-event-history-notice")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Inspect/ }).first().click();
|
||||
await page.getByRole("tab", { name: "Pipeline", exact: true }).click();
|
||||
await expect(page.getByText("Recent activity · 1,000 events")).toBeVisible();
|
||||
|
||||
@@ -39,7 +39,8 @@ it.each([LogViewer, ProductionLogViewer])("starts from the recent event tail and
|
||||
message: `event-${seq}`,
|
||||
payload: null,
|
||||
createdAt: new Date("2026-09-10T12:00:00Z"),
|
||||
...(seq === 1_004 ? { historyAfter: true } : {}),
|
||||
...(seq === 0 ? { historyBefore: true } : {}),
|
||||
...(seq === 1_004 ? { historyAfter: false } : {}),
|
||||
}));
|
||||
events.mockReturnValue(eventRows as never[]);
|
||||
const run = { id: "run-1", companyId: "company-1", agentId: "agent-1", status: "succeeded", logRef: null } as HeartbeatRun;
|
||||
@@ -64,6 +65,39 @@ it.each([LogViewer, ProductionLogViewer])("starts from the recent event tail and
|
||||
}
|
||||
});
|
||||
|
||||
it.each([LogViewer, ProductionLogViewer])("shows the omission notice from the tail page's historyBefore marker (%#)", async (Viewer) => {
|
||||
const eventRow = {
|
||||
id: 10,
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
seq: 10,
|
||||
eventType: "event",
|
||||
stream: "system" as const,
|
||||
level: "info" as const,
|
||||
color: null,
|
||||
message: "recent event",
|
||||
payload: null,
|
||||
createdAt: new Date("2026-09-10T12:00:00Z"),
|
||||
historyBefore: true as const,
|
||||
historyAfter: false,
|
||||
};
|
||||
events.mockReturnValue([eventRow] as never[]);
|
||||
const run = { id: "run-1", companyId: "company-1", agentId: "agent-1", status: "succeeded", logRef: null } as HeartbeatRun;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<Viewer run={run} adapterType="codex_local" onOpenInspector={vi.fn()} />));
|
||||
expect(events).toHaveBeenCalledWith("run-1", "tail", 200);
|
||||
expect(container.textContent).toContain("Events (1)");
|
||||
expect(container.querySelector('[data-testid="run-event-history-notice"]')?.textContent).toContain("browse older activity");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([LogViewer, ProductionLogViewer])("retains legacy history and reads only the next offset on visibility recovery (%#)", async (Viewer) => {
|
||||
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
const row = (seq: number, chunk: string) => JSON.stringify({ seq, ts: `2026-09-10T12:00:0${seq}Z`, stream: "stdout", chunk }) + "\n";
|
||||
|
||||
@@ -3738,8 +3738,8 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
|
||||
export function LogViewer({ run, adapterType, onOpenInspector }: { run: HeartbeatRun; adapterType: string; onOpenInspector?: () => void }) {
|
||||
const { visible } = usePageVisibility();
|
||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||
const [eventHistoryOmitted, setEventHistoryOmitted] = useState(false);
|
||||
const [eventState, setEventState] = useState({ events: [] as HeartbeatRunEvent[], historyOmitted: false });
|
||||
const { events, historyOmitted: eventHistoryOmitted } = eventState;
|
||||
const [logLines, setLogLines] = useState<RunLogChunk[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||
@@ -3824,23 +3824,20 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEvents([]);
|
||||
setEventHistoryOmitted(false);
|
||||
setEventState({ events: [], historyOmitted: false });
|
||||
}, [run.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEvents) {
|
||||
const retained = retainEventTail(initialEvents);
|
||||
setEvents(retained.events);
|
||||
setEventHistoryOmitted(retained.collapsed || initialEvents.at(-1)?.historyAfter === true);
|
||||
setEventState({
|
||||
events: retained.events,
|
||||
historyOmitted: retained.collapsed || initialEvents[0]?.historyBefore === true,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (retainEventTail(events).collapsed) setEventHistoryOmitted(true);
|
||||
}, [events]);
|
||||
|
||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||
const container = findScrollContainer(logEndRef.current);
|
||||
@@ -4004,7 +4001,10 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
|
||||
if (cancelled) return;
|
||||
if (newEvents.length > 0) {
|
||||
setEvents((prev) => retainEventTail(mergeRunEvents(prev, newEvents)).events);
|
||||
setEventState((prev) => {
|
||||
const retained = retainEventTail(mergeRunEvents(prev.events, newEvents));
|
||||
return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
@@ -4145,8 +4145,9 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
createdAt: new Date(event.createdAt),
|
||||
};
|
||||
|
||||
setEvents((prev) => {
|
||||
return retainEventTail(mergeRunEvents(prev, [liveEvent])).events;
|
||||
setEventState((prev) => {
|
||||
const retained = retainEventTail(mergeRunEvents(prev.events, [liveEvent]));
|
||||
return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3983,8 +3983,8 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
|
||||
export function LogViewer({ run, adapterType, onOpenInspector }: { run: HeartbeatRun; adapterType: string; onOpenInspector?: () => void }) {
|
||||
const { visible } = usePageVisibility();
|
||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||
const [eventHistoryOmitted, setEventHistoryOmitted] = useState(false);
|
||||
const [eventState, setEventState] = useState({ events: [] as HeartbeatRunEvent[], historyOmitted: false });
|
||||
const { events, historyOmitted: eventHistoryOmitted } = eventState;
|
||||
const [logLines, setLogLines] = useState<RunLogChunk[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||
@@ -4069,23 +4069,20 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEvents([]);
|
||||
setEventHistoryOmitted(false);
|
||||
setEventState({ events: [], historyOmitted: false });
|
||||
}, [run.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEvents) {
|
||||
const retained = retainEventTail(initialEvents);
|
||||
setEvents(retained.events);
|
||||
setEventHistoryOmitted(retained.collapsed || initialEvents.at(-1)?.historyAfter === true);
|
||||
setEventState({
|
||||
events: retained.events,
|
||||
historyOmitted: retained.collapsed || initialEvents[0]?.historyBefore === true,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (retainEventTail(events).collapsed) setEventHistoryOmitted(true);
|
||||
}, [events]);
|
||||
|
||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||
const container = findScrollContainer(logEndRef.current);
|
||||
@@ -4249,7 +4246,10 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
|
||||
if (cancelled) return;
|
||||
if (newEvents.length > 0) {
|
||||
setEvents((prev) => retainEventTail(mergeRunEvents(prev, newEvents)).events);
|
||||
setEventState((prev) => {
|
||||
const retained = retainEventTail(mergeRunEvents(prev.events, newEvents));
|
||||
return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
@@ -4390,8 +4390,9 @@ export function LogViewer({ run, adapterType, onOpenInspector }: { run: Heartbea
|
||||
createdAt: new Date(event.createdAt),
|
||||
};
|
||||
|
||||
setEvents((prev) => {
|
||||
return retainEventTail(mergeRunEvents(prev, [liveEvent])).events;
|
||||
setEventState((prev) => {
|
||||
const retained = retainEventTail(mergeRunEvents(prev.events, [liveEvent]));
|
||||
return { events: retained.events, historyOmitted: prev.historyOmitted || retained.collapsed };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user