Merge pull request #1604 from pikasTech/fix/1596-trace-page-order
fix: normalize workbench trace event page cursors
This commit is contained in:
@@ -58,6 +58,81 @@ test("workbench projection diagnostics keeps projecting health distinct from cau
|
||||
assert.equal(diagnostics.projectionHealth, "projecting");
|
||||
});
|
||||
|
||||
test("workbench trace event page exposes monotonic cursor range for restored mixed events", async () => {
|
||||
const traceId = "trc_workbench_trace_mixed_cursor";
|
||||
const session = {
|
||||
id: "ses_workbench_trace_mixed_cursor",
|
||||
projectId: "prj_workbench_authority_session",
|
||||
agentId: "hwlab-code-agent",
|
||||
status: "completed",
|
||||
ownerUserId: ACTOR.id,
|
||||
conversationId: "cnv_workbench_trace_mixed_cursor",
|
||||
threadId: "thread-workbench-trace-mixed-cursor",
|
||||
lastTraceId: traceId,
|
||||
updatedAt: "2026-06-19T04:10:17.060Z",
|
||||
session: { sessionStatus: "completed", lastTraceId: traceId, messages: [{ role: "user", text: "trace", traceId }] }
|
||||
};
|
||||
const mixedEvents = [
|
||||
{ seq: 1, source: "agentrun", sourceSeq: 1, type: "backend", status: "running", label: "agentrun:backend:run-created", createdAt: "2026-06-19T01:27:27.092Z" },
|
||||
{ seq: 5, source: "agentrun", sourceSeq: 1, type: "backend", status: "running", label: "agentrun:backend:run-created", createdAt: "2026-06-19T01:27:27.092Z" },
|
||||
{ seq: 6, source: "agentrun", sourceSeq: 4, type: "backend", status: "running", label: "agentrun:backend:runner-image-work-ready-smoke", createdAt: "2026-06-19T01:27:34.128Z" },
|
||||
{ seq: 2, source: "agentrun", sourceSeq: 4, type: "backend", status: "running", label: "agentrun:backend:runner-image-work-ready-smoke", createdAt: "2026-06-19T01:27:34.128Z" },
|
||||
{ seq: 3, source: "agentrun", sourceSeq: 5, type: "backend", status: "running", label: "agentrun:backend:run-claimed", createdAt: "2026-06-19T01:27:34.324Z" },
|
||||
{ seq: 7, source: "agentrun", sourceSeq: 5, type: "backend", status: "running", label: "agentrun:backend:run-claimed", createdAt: "2026-06-19T01:27:34.324Z" },
|
||||
{ seq: 4, type: "result", status: "completed", label: "agentrun:result:completed", terminal: true, createdAt: "2026-06-19T01:27:34.324Z" },
|
||||
{ seq: 1, type: "request", status: "accepted", label: "agentrun:request:accepted", createdAt: "2026-06-19T04:10:15.362Z" },
|
||||
{ seq: 2, type: "backend", status: "running", label: "agentrun:run:reused", createdAt: "2026-06-19T04:10:15.664Z" },
|
||||
{ seq: 3, type: "backend", status: "running", label: "agentrun:command:created", createdAt: "2026-06-19T04:10:16.013Z" },
|
||||
{ seq: 4, type: "backend", status: "running", label: "agentrun:runner-job:ensured", createdAt: "2026-06-19T04:10:17.060Z" }
|
||||
];
|
||||
const accessController = {
|
||||
store: {
|
||||
async getAgentSession(sessionId) { return sessionId === session.id ? session : null; },
|
||||
async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
|
||||
},
|
||||
async ensureBootstrap() {},
|
||||
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
|
||||
};
|
||||
const runtimeStore = {
|
||||
async queryAgentTraceEvents(input = {}) {
|
||||
assert.equal(input.traceId, traceId);
|
||||
return { events: mixedEvents, count: mixedEvents.length };
|
||||
}
|
||||
};
|
||||
const server = createCloudApiServer({ accessController, runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=25`);
|
||||
assert.equal(trace.status, 200);
|
||||
assert.equal(trace.body.events.length, mixedEvents.length);
|
||||
assert.deepEqual(trace.body.events.map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||
assert.equal(trace.body.events[0].label, "agentrun:backend:run-created");
|
||||
assert.equal(trace.body.events.at(-1).label, "agentrun:runner-job:ensured");
|
||||
assert.deepEqual(trace.body.range, { afterSeq: 0, fromSeq: 1, toSeq: 11, limit: 25, returned: 11, total: 11 });
|
||||
assert.equal(trace.body.nextSeq, 11);
|
||||
assert.equal(trace.body.hasMore, false);
|
||||
assert.equal(trace.body.nextCursor, null);
|
||||
|
||||
const middle = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?cursor=seq:4&limit=3`);
|
||||
assert.equal(middle.status, 200);
|
||||
assert.deepEqual(middle.body.events.map((event) => event.seq), [5, 6, 7]);
|
||||
assert.deepEqual(middle.body.range, { afterSeq: 4, fromSeq: 5, toSeq: 7, limit: 3, returned: 3, total: 11 });
|
||||
assert.equal(middle.body.hasMore, true);
|
||||
assert.equal(middle.body.nextSeq, 7);
|
||||
assert.equal(middle.body.nextCursor, "seq:7");
|
||||
|
||||
const empty = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?cursor=seq:999999&limit=5`);
|
||||
assert.equal(empty.status, 200);
|
||||
assert.deepEqual(empty.body.events, []);
|
||||
assert.deepEqual(empty.body.range, { afterSeq: 999999, fromSeq: null, toSeq: null, limit: 5, returned: 0, total: 11 });
|
||||
assert.equal(empty.body.nextSeq, 999999);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench read model exposes session, messages, turn, and trace without write repair", async () => {
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
const results = createCodeAgentChatResultStore();
|
||||
|
||||
@@ -730,7 +730,7 @@ function turnSnapshot({ projection, result, session, trace }) {
|
||||
|
||||
function traceEventPage(snapshot, options) {
|
||||
const sourceEvents = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
||||
const indexed = sourceEvents.map((event, index) => ({ event, seq: eventSeq(event, index) }));
|
||||
const indexed = normalizedTracePageEvents(sourceEvents);
|
||||
const startIndex = indexed.findIndex((item) => item.seq > options.afterSeq);
|
||||
const offset = startIndex >= 0 ? startIndex : indexed.length;
|
||||
const page = indexed.slice(offset, offset + options.limit);
|
||||
@@ -756,6 +756,39 @@ function traceEventPage(snapshot, options) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedTracePageEvents(sourceEvents) {
|
||||
return sourceEvents
|
||||
.map((event, index) => ({
|
||||
event,
|
||||
sourceIndex: index,
|
||||
persistedSeq: eventSeq(event, index),
|
||||
sourceSeq: positiveTraceInteger(event?.sourceSeq),
|
||||
createdAtMs: traceEventTimestampMs(event)
|
||||
}))
|
||||
.sort(compareTracePageEvents)
|
||||
.map((item, index) => ({ event: item.event, seq: index + 1 }));
|
||||
}
|
||||
|
||||
function compareTracePageEvents(left, right) {
|
||||
if (left.createdAtMs !== right.createdAtMs) return left.createdAtMs - right.createdAtMs;
|
||||
if (left.sourceSeq !== right.sourceSeq) return left.sourceSeq - right.sourceSeq;
|
||||
if (left.persistedSeq !== right.persistedSeq) return left.persistedSeq - right.persistedSeq;
|
||||
return left.sourceIndex - right.sourceIndex;
|
||||
}
|
||||
|
||||
function traceEventTimestampMs(event) {
|
||||
for (const value of [event?.createdAt, event?.occurredAt, event?.timestamp, event?.appendedAt]) {
|
||||
const parsed = Date.parse(String(value ?? ""));
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function positiveTraceInteger(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function tracePageOptions(url) {
|
||||
const cursor = textValue(url.searchParams.get("cursor"));
|
||||
const cursorSeq = cursor.startsWith("seq:") ? Number.parseInt(cursor.slice(4), 10) : NaN;
|
||||
|
||||
@@ -35,6 +35,7 @@ test.describe("durable projector resume", () => {
|
||||
expect(traceBody.traceStatus).toBe("completed");
|
||||
expect(traceBody.projectionStatus).toBe("caught-up");
|
||||
expect(traceBody.projectionHealth).toBe("caught-up");
|
||||
expect(traceBody.events.map((event: { seq?: number }) => event.seq)).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(traceBody.range).toMatchObject({ afterSeq: 0, fromSeq: 1, toSeq: 7, returned: 7, total: 7 });
|
||||
|
||||
const emptyTracePage = await page.request.get(`/v1/workbench/traces/${traceId}/events?cursor=seq:999999&limit=5`);
|
||||
@@ -47,6 +48,7 @@ test.describe("durable projector resume", () => {
|
||||
const finalCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).filter({ hasText: "fake projector resume completed from durable checkpoint" });
|
||||
await expect(finalCard).toHaveAttribute("data-status", "completed");
|
||||
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`).last()).toBeVisible();
|
||||
expect(await finalCard.innerText()).not.toContain("暂无可读 Trace");
|
||||
await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn");
|
||||
await saveScreenshot(page, testInfo, "projector-resume-after-catchup");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user