diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 7aec058b..4ff6aeb0 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -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(); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index c24ced08..5e6cb9ae 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -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; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts index b1a4c091..f620ea7f 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts @@ -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");