diff --git a/packages/api/src/postgres/trace-repository.ts b/packages/api/src/postgres/trace-repository.ts index 710c7d1..aaf390e 100644 --- a/packages/api/src/postgres/trace-repository.ts +++ b/packages/api/src/postgres/trace-repository.ts @@ -64,10 +64,12 @@ export class TraceRepository { LEFT JOIN app.run_trace_projections p ON p.tenant_id=r.tenant_id AND p.run_id=r.id WHERE r.tenant_id=$1 AND r.id=$2 ), bound AS (SELECT revision,($5::text IS NOT NULL AND revision<>$5) reset FROM meta) - SELECT revision,reset,coalesce((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.sequence) FROM ( + SELECT revision,reset,coalesce((SELECT jsonb_agg(to_jsonb(e) ORDER BY CASE WHEN $6::boolean OR $7::bigint IS NOT NULL THEN -e.sequence ELSE e.sequence END) FROM ( SELECT sequence,id,parent_id,event_type,role,title,preview_text,display_data,occurred_at FROM app.run_trace_events WHERE tenant_id=$1 AND run_id=$2 - AND (CASE WHEN $6::boolean OR $7::bigint IS NOT NULL THEN sequence < COALESCE($7::bigint, (SELECT max(sequence) + 1 FROM app.run_trace_events WHERE tenant_id=$1 AND run_id=$2)) ELSE sequence > CASE WHEN reset THEN 0 ELSE $3 END END) + AND (CASE WHEN $6::boolean THEN sequence <= COALESCE((SELECT max(sequence) FROM app.run_trace_events WHERE tenant_id=$1 AND run_id=$2), 0) + WHEN $7::bigint IS NOT NULL THEN sequence < $7::bigint + ELSE sequence > CASE WHEN reset THEN 0 ELSE $3 END END) ORDER BY CASE WHEN $6::boolean OR $7::bigint IS NOT NULL THEN sequence END DESC, CASE WHEN NOT ($6::boolean OR $7::bigint IS NOT NULL) THEN sequence END ASC LIMIT $4 ) e),'[]'::jsonb) events FROM bound`, [this.tenantId, runId, after, limit + 1, expectedRevision ?? null, recent, before ?? null]); const row = result.rows[0]; diff --git a/packages/api/test/run-reads.postgres.test.ts b/packages/api/test/run-reads.postgres.test.ts index 4189538..087829a 100644 --- a/packages/api/test/run-reads.postgres.test.ts +++ b/packages/api/test/run-reads.postgres.test.ts @@ -40,6 +40,19 @@ describe.skipIf(!url)("lightweight status and revision-bound traces", () => { expect(await new TraceRepository(f.db, f.otherTenant).revisionPage(f.runId, 0, 2)).toBeNull(); expect((await new ApiService(f.env, f.auth).getRunStatus(f.runId)).trace_revision).toBe(reset.revision); }); + it("returns real recent and before pages without dropping or repeating boundaries", async () => { + const traces = new TraceRepository(f.db, f.tenantId); + await traces.replace(f.runId, Array.from({ length: 162 }, (_, i) => ({ id: `page-${i + 1}`, sequence: i + 1, type: "assistant_message" as const, title: `Event ${i + 1}`, preview: "Text", display: {} }))); + + const recent = (await traces.revisionPage(f.runId, 0, 100, undefined, undefined, true))!; + expect(recent.items.map(event => event.sequence)).toEqual(Array.from({ length: 100 }, (_, i) => i + 63)); + expect(recent.previousCursor).toBe(63); + + const older = (await traces.revisionPage(f.runId, 0, 100, recent.revision, recent.previousCursor!))!; + expect(older.items.map(event => event.sequence)).toEqual(Array.from({ length: 62 }, (_, i) => i + 1)); + expect(older.previousCursor).toBeNull(); + expect(new Set([...recent.items, ...older.items].map(event => event.sequence)).size).toBe(162); + }); it("distinguishes terminal execution from finalization and accepts legacy done filters", async () => { await f.db.pool.query("UPDATE app.job_runs SET state='succeeded' WHERE tenant_id=$1 AND id=$2", [f.tenantId, f.runId]); await f.db.pool.query("UPDATE app.runs SET state='succeeded',artifact_state='collecting',vm_cleanup_complete=false WHERE tenant_id=$1 AND id=$2", [f.tenantId, f.runId]); diff --git a/packages/app/src/features/runs/continuous-trace.tsx b/packages/app/src/features/runs/continuous-trace.tsx index 15585ca..d105a1f 100644 --- a/packages/app/src/features/runs/continuous-trace.tsx +++ b/packages/app/src/features/runs/continuous-trace.tsx @@ -69,6 +69,7 @@ export function ContinuousTrace({ runId, revision, active, onReset }: { runId: s if (!element) return; const bottom = element.scrollHeight - element.scrollTop - element.clientHeight <= 48; followRef.current = bottom; + if (element.scrollTop <= 8 && trace.hasNextPage && !trace.isFetching && !historyLoadingRef.current) void loadHistory(3); }; const loadHistory = async (pageCount = 1) => { const element = scrollRef.current; diff --git a/packages/app/test/runs.browser.test.mjs b/packages/app/test/runs.browser.test.mjs index b2f5843..cc17546 100644 --- a/packages/app/test/runs.browser.test.mjs +++ b/packages/app/test/runs.browser.test.mjs @@ -125,7 +125,7 @@ test("run trace uses pull-to-load history, preserves the anchor, and never crash const { context, page } = await setup(); const requests = []; const event = (sequence, type = "assistant_message") => ({ id: `trace-${sequence}`, sequence, type, title: `Trace ${sequence}`, preview: `Trace ${sequence}`, display: {} }); const recent = [...Array.from({ length: 14 }, (_, index) => event(index + 11)), event(25, "tool_call"), event(26, "tool_result")]; - const olderPages = [Array.from({ length: 10 }, (_, index) => event(index + 1)), Array.from({ length: 10 }, (_, index) => event(index + 27)), Array.from({ length: 10 }, (_, index) => event(index + 37))]; + const olderPages = [Array.from({ length: 10 }, (_, index) => event(index + 1)), Array.from({ length: 10 }, (_, index) => event(index + 27)), Array.from({ length: 10 }, (_, index) => event(index + 37)), Array.from({ length: 10 }, (_, index) => event(index + 47)), Array.from({ length: 10 }, (_, index) => event(index + 57)), Array.from({ length: 10 }, (_, index) => event(index + 67))]; await page.route(`**/api/v1/runs/${runId}/status`, route => route.fulfill({ json: { id: runId, job_id: jobId, job_name: "Job", run_name: "Run", state: "succeeded", finalizing: false, trace_revision: "rev", artifact_state: "stored", created_at: time, updated_at: time, started_at: time, destination_url: null } })); await page.route(`**/api/v1/runs/${runId}/trace-pages?**`, route => { const url = new URL(route.request().url()); requests.push(url); @@ -145,12 +145,15 @@ test("run trace uses pull-to-load history, preserves the anchor, and never crash const initial = await scroller.evaluate(element => ({ top: element.scrollTop, height: element.scrollHeight, client: element.clientHeight })); assert.ok(initial.height > initial.client && initial.top > 0, "initial trace should be bottom-aligned inside its own scroller"); const before = await scroller.evaluate(element => element.scrollHeight); + await scroller.evaluate(element => { element.scrollTop = 0; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); + await page.waitForFunction(() => document.querySelectorAll("[data-run-trace] article").length >= 44); + assert.equal(requests.filter(url => url.searchParams.has("before")).length, 3, "desktop top scroll should load history"); await scroller.evaluate(element => { element.scrollTop = 0; const event = new Event("touchstart", { bubbles: true }); Object.defineProperty(event, "touches", { value: [{ clientY: 100 }] }); element.dispatchEvent(event); }); await scroller.evaluate(element => { const event = new Event("touchmove", { bubbles: true }); Object.defineProperty(event, "touches", { value: [{ clientY: 180 }] }); element.dispatchEvent(event); }); await scroller.evaluate(element => { const event = new Event("touchend", { bubbles: true }); Object.defineProperty(event, "touches", { value: [] }); element.dispatchEvent(event); }); - await page.waitForFunction(() => document.querySelectorAll("[data-run-trace] article").length >= 44); + await page.waitForFunction(() => document.querySelectorAll("[data-run-trace] article").length >= 74); const after = await scroller.evaluate(element => ({ top: element.scrollTop, height: element.scrollHeight })); - assert.equal(requests.filter(url => url.searchParams.has("before")).length, 3, "a pull should load several history pages"); + assert.equal(requests.filter(url => url.searchParams.has("before")).length, 6, "a pull should load several history pages"); assert.ok(after.top > 0 && after.height > before, "prepending history should preserve the visible anchor"); await page.setViewportSize({ width: 900, height: 700 }); const shortHeight = await scroller.evaluate(element => element.clientHeight); await page.setViewportSize({ width: 900, height: 900 }); const tallHeight = await scroller.evaluate(element => element.clientHeight);