* fix(app): keep timeline pagination duplicate-free Older daemons page projected history by canonical sequence ranges, so one projected block can appear in adjacent responses. Accept each block on the page containing its projection anchor while still advancing across empty projected pages.\n\nProjected siblings can share a provider message ID, so render identity also includes the timeline cursor instead of collapsing distinct blocks onto one layout row. * fix(e2e): preserve isolated daemon launch contract Keep the source-daemon spawn path recognizable to the repository launch-contract check while selecting the built supervisor only for published package fixtures. * fix(app): preserve stable timeline item identity
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
|
|
|
type WebSocketMessage = string | Buffer;
|
|
|
|
interface CreatedAgentTimelineGate {
|
|
release(): void;
|
|
waitForCreatedAgent(): Promise<string>;
|
|
waitForDelayedResponse(): Promise<void>;
|
|
waitForForwardedResponse(): Promise<void>;
|
|
}
|
|
|
|
export interface AgentTimelineResponseGate {
|
|
release(): void;
|
|
waitForDelayedResponse(): Promise<void>;
|
|
}
|
|
|
|
export interface OlderTimelinePagesGate {
|
|
getRequestCount(): number;
|
|
getRepeatedEntryCount(): number;
|
|
getOwnedEntryCountContaining(text: string): number;
|
|
releasePage(pageNumber: number): void;
|
|
waitForRequestCount(count: number): Promise<void>;
|
|
}
|
|
|
|
export interface DaemonHydrationGate {
|
|
release(): void;
|
|
}
|
|
|
|
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
|
|
releaseCatchUp(): void;
|
|
waitForDelayedCatchUp(): Promise<void>;
|
|
}
|
|
|
|
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
|
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
|
try {
|
|
return JSON.parse(rawMessage);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
|
const envelope = parseWebSocketJson(message);
|
|
if (!envelope || typeof envelope !== "object") {
|
|
return null;
|
|
}
|
|
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
|
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
|
return null;
|
|
}
|
|
if (typeof maybeEnvelope.message !== "object") {
|
|
return null;
|
|
}
|
|
return maybeEnvelope.message as Record<string, unknown>;
|
|
}
|
|
|
|
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
|
|
return message.payload && typeof message.payload === "object"
|
|
? (message.payload as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
interface ObservedTimelineEntry {
|
|
seqStart: number;
|
|
seqEnd: number;
|
|
item: { type: string };
|
|
}
|
|
|
|
function readTimelineEntries(payload: Record<string, unknown>): ObservedTimelineEntry[] {
|
|
if (!Array.isArray(payload.entries)) return [];
|
|
return payload.entries.filter((entry): entry is ObservedTimelineEntry => {
|
|
if (!entry || typeof entry !== "object") return false;
|
|
const candidate = entry as Partial<ObservedTimelineEntry>;
|
|
return (
|
|
typeof candidate.seqStart === "number" &&
|
|
typeof candidate.seqEnd === "number" &&
|
|
typeof candidate.item?.type === "string"
|
|
);
|
|
});
|
|
}
|
|
|
|
function readCursorSeq(cursor: unknown): number | undefined {
|
|
if (!cursor || typeof cursor !== "object") return undefined;
|
|
const seq = (cursor as { seq?: unknown }).seq;
|
|
return typeof seq === "number" ? seq : undefined;
|
|
}
|
|
|
|
function recordOwnedTimelineEntries(
|
|
payload: Record<string, unknown>,
|
|
ownedEntries: Map<string, string>,
|
|
): void {
|
|
const pageStartSeq = readCursorSeq(payload.startCursor);
|
|
const pageEndSeq = readCursorSeq(payload.endCursor);
|
|
for (const entry of readTimelineEntries(payload)) {
|
|
const belongsToPage =
|
|
payload.direction === "tail" ||
|
|
(pageStartSeq !== undefined &&
|
|
pageEndSeq !== undefined &&
|
|
entry.seqStart >= pageStartSeq &&
|
|
entry.seqStart <= pageEndSeq);
|
|
if (!belongsToPage) continue;
|
|
ownedEntries.set(`${entry.seqStart}:${entry.seqEnd}`, JSON.stringify(entry.item));
|
|
}
|
|
}
|
|
|
|
function recordRepeatedTimelineEntries(
|
|
payload: Record<string, unknown>,
|
|
entryKeys: Set<string>,
|
|
): number {
|
|
let repeats = 0;
|
|
for (const entry of readTimelineEntries(payload)) {
|
|
const key = `${entry.seqStart}:${entry.seqEnd}:${entry.item.type}`;
|
|
if (entryKeys.has(key)) repeats += 1;
|
|
else entryKeys.add(key);
|
|
}
|
|
return repeats;
|
|
}
|
|
|
|
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
|
|
let released = false;
|
|
const delayedForwards: Array<() => void> = [];
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => server.send(message));
|
|
server.onMessage((message) => {
|
|
if (released) {
|
|
ws.send(message);
|
|
return;
|
|
}
|
|
delayedForwards.push(() => ws.send(message));
|
|
});
|
|
});
|
|
|
|
return {
|
|
release() {
|
|
released = true;
|
|
for (const forward of delayedForwards.splice(0)) {
|
|
forward();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function delayCreatedAgentInitialTailResponse(
|
|
page: Page,
|
|
): Promise<CreatedAgentTimelineGate> {
|
|
let createdAgentId: string | null = null;
|
|
let releaseRequested = false;
|
|
let delayedResponseSeen = false;
|
|
const delayedForwards: Array<() => void> = [];
|
|
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
|
|
let resolveDelayedResponse: (() => void) | null = null;
|
|
let resolveForwardedResponse: (() => void) | null = null;
|
|
const createdAgentSeen = new Promise<string>((resolve) => {
|
|
resolveCreatedAgent = resolve;
|
|
});
|
|
const delayedResponse = new Promise<void>((resolve) => {
|
|
resolveDelayedResponse = resolve;
|
|
});
|
|
const forwardedResponse = new Promise<void>((resolve) => {
|
|
resolveForwardedResponse = resolve;
|
|
});
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
const forwardToClient = (message: WebSocketMessage) => {
|
|
ws.send(message);
|
|
resolveForwardedResponse?.();
|
|
};
|
|
|
|
ws.onMessage((message) => {
|
|
server.send(message);
|
|
});
|
|
|
|
server.onMessage((message) => {
|
|
const sessionMessage = getSessionMessage(message);
|
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
|
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
|
|
const agentId = payload.agentId;
|
|
if (typeof agentId === "string") {
|
|
createdAgentId = agentId;
|
|
resolveCreatedAgent?.(agentId);
|
|
}
|
|
}
|
|
|
|
if (sessionMessage?.type === "fetch_agent_timeline_response") {
|
|
const agentId = payload?.agentId;
|
|
const direction = payload?.direction;
|
|
if (
|
|
!delayedResponseSeen &&
|
|
typeof agentId === "string" &&
|
|
agentId === createdAgentId &&
|
|
direction === "tail"
|
|
) {
|
|
delayedResponseSeen = true;
|
|
resolveDelayedResponse?.();
|
|
if (releaseRequested) {
|
|
forwardToClient(message);
|
|
return;
|
|
}
|
|
delayedForwards.push(() => forwardToClient(message));
|
|
return;
|
|
}
|
|
}
|
|
|
|
ws.send(message);
|
|
});
|
|
});
|
|
|
|
return {
|
|
release() {
|
|
releaseRequested = true;
|
|
for (const forward of delayedForwards.splice(0)) {
|
|
forward();
|
|
}
|
|
},
|
|
waitForCreatedAgent: () => createdAgentSeen,
|
|
waitForDelayedResponse: () => delayedResponse,
|
|
waitForForwardedResponse: () => forwardedResponse,
|
|
};
|
|
}
|
|
|
|
export async function delayAgentOlderTimelineResponse(
|
|
page: Page,
|
|
agentId: string,
|
|
): Promise<AgentTimelineResponseGate> {
|
|
return delayAgentTimelineResponse(page, agentId, "before");
|
|
}
|
|
|
|
export async function holdAgentOlderTimelinePages(
|
|
page: Page,
|
|
agentId: string,
|
|
daemonPort?: number,
|
|
): Promise<OlderTimelinePagesGate> {
|
|
let requestCount = 0;
|
|
let responseCount = 0;
|
|
let repeatedEntryCount = 0;
|
|
const entryKeys = new Set<string>();
|
|
const ownedEntries = new Map<string, string>();
|
|
const releasedPages = new Set<number>();
|
|
const delayedForwards = new Map<number, Array<() => void>>();
|
|
const requestWaiters = new Map<number, Array<() => void>>();
|
|
|
|
const resolveRequestWaiters = () => {
|
|
for (const [count, resolvers] of requestWaiters) {
|
|
if (requestCount < count) continue;
|
|
requestWaiters.delete(count);
|
|
for (const resolve of resolvers) resolve();
|
|
}
|
|
};
|
|
|
|
const routePattern =
|
|
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
|
await page.routeWebSocket(routePattern, (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => {
|
|
const sessionMessage = getSessionMessage(message);
|
|
if (
|
|
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
|
sessionMessage.agentId === agentId &&
|
|
sessionMessage.direction === "before"
|
|
) {
|
|
requestCount += 1;
|
|
resolveRequestWaiters();
|
|
}
|
|
server.send(message);
|
|
});
|
|
server.onMessage((message) => {
|
|
const sessionMessage = getSessionMessage(message);
|
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
|
if (
|
|
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
|
payload?.agentId === agentId &&
|
|
(payload.direction === "tail" || payload.direction === "before")
|
|
) {
|
|
recordOwnedTimelineEntries(payload, ownedEntries);
|
|
}
|
|
if (
|
|
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
|
payload?.agentId === agentId &&
|
|
payload.direction === "before"
|
|
) {
|
|
responseCount += 1;
|
|
repeatedEntryCount += recordRepeatedTimelineEntries(payload, entryKeys);
|
|
const pageNumber = responseCount;
|
|
if (releasedPages.has(pageNumber)) {
|
|
ws.send(message);
|
|
return;
|
|
}
|
|
const forwards = delayedForwards.get(pageNumber) ?? [];
|
|
forwards.push(() => ws.send(message));
|
|
delayedForwards.set(pageNumber, forwards);
|
|
return;
|
|
}
|
|
ws.send(message);
|
|
});
|
|
});
|
|
|
|
return {
|
|
getRequestCount: () => requestCount,
|
|
getRepeatedEntryCount: () => repeatedEntryCount,
|
|
getOwnedEntryCountContaining: (value) =>
|
|
[...ownedEntries.values()].filter((text) => text.includes(value)).length,
|
|
releasePage(pageNumber) {
|
|
releasedPages.add(pageNumber);
|
|
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
|
delayedForwards.delete(pageNumber);
|
|
},
|
|
waitForRequestCount(count) {
|
|
if (requestCount >= count) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
const resolvers = requestWaiters.get(count) ?? [];
|
|
resolvers.push(resolve);
|
|
requestWaiters.set(count, resolvers);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function delayAgentBootstrapTailResponse(
|
|
page: Page,
|
|
agentId: string,
|
|
): Promise<BootstrapTimelineGate> {
|
|
let tailReleased = false;
|
|
let catchUpReleased = false;
|
|
const delayedTailForwards: Array<() => void> = [];
|
|
const delayedCatchUpForwards: Array<() => void> = [];
|
|
let resolveDelayedTail: (() => void) | null = null;
|
|
let resolveDelayedCatchUp: (() => void) | null = null;
|
|
const delayedTail = new Promise<void>((resolve) => {
|
|
resolveDelayedTail = resolve;
|
|
});
|
|
const delayedCatchUp = new Promise<void>((resolve) => {
|
|
resolveDelayedCatchUp = resolve;
|
|
});
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => server.send(message));
|
|
server.onMessage((message) => {
|
|
const sessionMessage = getSessionMessage(message);
|
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
|
const isTimelineResponse =
|
|
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
|
|
if (isTimelineResponse && payload.direction === "tail") {
|
|
resolveDelayedTail?.();
|
|
if (tailReleased) ws.send(message);
|
|
else delayedTailForwards.push(() => ws.send(message));
|
|
return;
|
|
}
|
|
if (isTimelineResponse && payload.direction === "after") {
|
|
resolveDelayedCatchUp?.();
|
|
if (catchUpReleased) ws.send(message);
|
|
else delayedCatchUpForwards.push(() => ws.send(message));
|
|
return;
|
|
}
|
|
ws.send(message);
|
|
});
|
|
});
|
|
|
|
return {
|
|
release() {
|
|
tailReleased = true;
|
|
for (const forward of delayedTailForwards.splice(0)) forward();
|
|
},
|
|
releaseCatchUp() {
|
|
catchUpReleased = true;
|
|
for (const forward of delayedCatchUpForwards.splice(0)) forward();
|
|
},
|
|
waitForDelayedResponse: () => delayedTail,
|
|
waitForDelayedCatchUp: () => delayedCatchUp,
|
|
};
|
|
}
|
|
|
|
async function delayAgentTimelineResponse(
|
|
page: Page,
|
|
agentId: string,
|
|
direction: "before" | "tail",
|
|
): Promise<AgentTimelineResponseGate> {
|
|
let releaseRequested = false;
|
|
let delayedResponseSeen = false;
|
|
const delayedForwards: Array<() => void> = [];
|
|
let resolveDelayedResponse: (() => void) | null = null;
|
|
const delayedResponse = new Promise<void>((resolve) => {
|
|
resolveDelayedResponse = resolve;
|
|
});
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => {
|
|
server.send(message);
|
|
});
|
|
server.onMessage((message) => {
|
|
const sessionMessage = getSessionMessage(message);
|
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
|
if (
|
|
!delayedResponseSeen &&
|
|
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
|
payload?.agentId === agentId &&
|
|
payload.direction === direction
|
|
) {
|
|
delayedResponseSeen = true;
|
|
resolveDelayedResponse?.();
|
|
if (releaseRequested) {
|
|
ws.send(message);
|
|
return;
|
|
}
|
|
delayedForwards.push(() => ws.send(message));
|
|
return;
|
|
}
|
|
ws.send(message);
|
|
});
|
|
});
|
|
|
|
return {
|
|
release() {
|
|
releaseRequested = true;
|
|
for (const forward of delayedForwards.splice(0)) {
|
|
forward();
|
|
}
|
|
},
|
|
waitForDelayedResponse: () => delayedResponse,
|
|
};
|
|
}
|