Fix mobile agent timeline catch-up
This commit is contained in:
@@ -919,6 +919,24 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps init open while an after catch-up page has newer rows", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
hasNewer: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
});
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
|
||||
@@ -167,12 +167,14 @@ function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
function shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
hasNewer,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
hasNewer: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
@@ -184,6 +186,9 @@ function shouldResolveTimelineInit({
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
if (responseDirection === "after" && hasNewer) {
|
||||
return false;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -652,12 +657,16 @@ export function processTimelineResponse(
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
hasNewer: payload.hasNewer,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
const timelineResponseComplete = payload.direction !== "after" || !payload.hasNewer;
|
||||
const clearInitializing =
|
||||
(shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred)) &&
|
||||
timelineResponseComplete;
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import {
|
||||
isTimelineCatchUpComplete,
|
||||
planInitialAgentTimelineSync,
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpFollowUp,
|
||||
planTimelineOlderFetch,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
describe("timeline sync planning", () => {
|
||||
test("initial open without an authoritative cursor loads a bounded tail page", () => {
|
||||
const plan = planInitialAgentTimelineSync({
|
||||
cursor: undefined,
|
||||
hasAuthoritativeHistory: false,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("initial open with an authoritative cursor catches up after the cursor", () => {
|
||||
const plan = planInitialAgentTimelineSync({
|
||||
cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("resume with a cursor catches up after the cursor", () => {
|
||||
const plan = planResumeTimelineSync({
|
||||
cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 100 },
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 100 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("resume without a cursor loads a bounded tail page", () => {
|
||||
const plan = planResumeTimelineSync({ cursor: undefined });
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("older history loads one bounded page before the start cursor", () => {
|
||||
const plan = planTimelineOlderFetch({ epoch: "epoch-1", seq: 25 });
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "before",
|
||||
cursor: { epoch: "epoch-1", seq: 25 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("catch-up keeps paging while the daemon reports newer rows", () => {
|
||||
const plan = planTimelineCatchUpFollowUp({
|
||||
direction: "after",
|
||||
hasNewer: true,
|
||||
endCursor: { epoch: "epoch-1", seq: 200 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 200 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
test("catch-up finishes when the daemon reports no newer rows", () => {
|
||||
const plan = planTimelineCatchUpFollowUp({
|
||||
direction: "after",
|
||||
hasNewer: false,
|
||||
endCursor: { epoch: "epoch-1", seq: 200 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(plan).toBeNull();
|
||||
expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
export interface TimelineSyncCursor {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface AgentTimelineCursorRange {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export interface CanonicalTimelineTailFetchPlan {
|
||||
direction: "tail";
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
}
|
||||
|
||||
export interface CanonicalTimelineAfterFetchPlan {
|
||||
direction: "after";
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
}
|
||||
|
||||
export interface CanonicalTimelineBeforeFetchPlan {
|
||||
direction: "before";
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
}
|
||||
|
||||
export type CanonicalTimelineFetchPlan =
|
||||
| CanonicalTimelineTailFetchPlan
|
||||
| CanonicalTimelineAfterFetchPlan
|
||||
| CanonicalTimelineBeforeFetchPlan;
|
||||
|
||||
export type CanonicalTimelineForwardFetchPlan =
|
||||
| CanonicalTimelineTailFetchPlan
|
||||
| CanonicalTimelineAfterFetchPlan;
|
||||
|
||||
export function planInitialAgentTimelineSync(input: {
|
||||
cursor: AgentTimelineCursorRange | undefined;
|
||||
hasAuthoritativeHistory: boolean;
|
||||
}): CanonicalTimelineForwardFetchPlan {
|
||||
if (input.hasAuthoritativeHistory && input.cursor) {
|
||||
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
|
||||
}
|
||||
|
||||
return planTimelineTailFetch();
|
||||
}
|
||||
|
||||
export function planResumeTimelineSync(input: {
|
||||
cursor: AgentTimelineCursorRange | undefined;
|
||||
}): CanonicalTimelineForwardFetchPlan {
|
||||
if (input.cursor) {
|
||||
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
|
||||
}
|
||||
|
||||
return planTimelineTailFetch();
|
||||
}
|
||||
|
||||
export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
|
||||
return {
|
||||
direction: "after",
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineTailFetch() {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
return {
|
||||
direction: "before",
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineCatchUpFollowUp(input: {
|
||||
direction: "tail" | "before" | "after";
|
||||
hasNewer: boolean;
|
||||
endCursor: TimelineSyncCursor | null;
|
||||
error: string | null;
|
||||
}): CanonicalTimelineAfterFetchPlan | null {
|
||||
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return planTimelineCatchUpAfter(input.endCursor);
|
||||
}
|
||||
|
||||
export function isTimelineCatchUpComplete(input: {
|
||||
direction: "tail" | "before" | "after";
|
||||
hasNewer: boolean;
|
||||
error: string | null;
|
||||
}): boolean {
|
||||
if (input.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return input.direction !== "after" || !input.hasNewer;
|
||||
}
|
||||
Reference in New Issue
Block a user