From d388bba402f291b2a85c6a5dd958dc9ede4e60f1 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 17 Sep 2026 20:06:49 -0700 Subject: [PATCH 1/2] fix: merge duplicate build log steps before reading output --- src/build-logs.ts | 18 ++++++++++++++++-- test/build-logs.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/build-logs.ts b/src/build-logs.ts index 753920e..3e6e202 100644 --- a/src/build-logs.ts +++ b/src/build-logs.ts @@ -7,7 +7,7 @@ export type BuildLogPage = { state: 'ready' | 'pending' | 'unsupported' | 'unavailable' buildState: string error?: string - steps: Array<{ digest: string; name: string; error?: string; hasLogs: boolean }> + steps: Array<{ digest: string; name: string; error?: string; hasLogs: boolean; completedAt?: string }> entries: Array<{ timestamp: string; message: string; occurrence?: number }> nextCursor?: string } @@ -21,6 +21,12 @@ type FollowState = { tails: Map; emit: (snapshot: BuildLogSnaps const entryKey = (step: string, entry: BuildLogPage['entries'][number]) => createHash('sha256').update(JSON.stringify([step, entry.timestamp, entry.message])).digest('hex') class LogsNotReady extends Error {} +// Compute emits UTC RFC3339Nano; Date.parse discards submillisecond ordering. +function completionKey(value: string): string { + const [seconds, fraction = ''] = value.slice(0, -1).split('.') + return `${seconds}.${fraction.padEnd(9, '0')}` +} + export async function readBuildLogs(api: Api, projectId: string, source: BuildSource, buildId: string, signal = AbortSignal.timeout(30_000), follow?: FollowState): Promise { let bytes = 0 let requests = 0 @@ -62,7 +68,15 @@ export async function readBuildLogs(api: Api, projectId: string, source: BuildSo const stepPages = await pages() const first = stepPages[0]! if (stepPages.some((page) => page.state !== first.state)) throw new LogsNotReady('build steps are temporarily unavailable') - const steps = stepPages.flatMap((page) => page.steps) + const byDigest = new Map() + for (const step of stepPages.flatMap((page) => page.steps)) { + const previous = byDigest.get(step.digest) + if (!previous) { byDigest.set(step.digest, step); continue } + const [older, newer] = previous.completedAt && (!step.completedAt || completionKey(previous.completedAt) > completionKey(step.completedAt)) + ? [step, previous] : [previous, step] + byDigest.set(step.digest, { ...older, ...newer, hasLogs: older.hasLogs || newer.hasLogs, error: newer.error || older.error }) + } + const steps = [...byDigest.values()] follow?.emit({ ...first, steps, output: [] }) const output: BuildLogSnapshot['output'] = [] for (const step of steps) { diff --git a/test/build-logs.test.ts b/test/build-logs.test.ts index f6eb131..54ae932 100644 --- a/test/build-logs.test.ts +++ b/test/build-logs.test.ts @@ -8,6 +8,22 @@ const entry = (message: string) => ({ timestamp: '2026-09-17T00:00:00Z', message const apiWith = (read: (path: string) => unknown): Pick => ({ rawRequest: vi.fn(async (_method, path) => ({ status: 200, body: read(path) })) }) describe('build logs', () => { + it.each([[false, false, false], [true, false, false], [false, true, false], [true, true, false], [false, true, true], [true, true, true]])('merges duplicate steps across pages (terminal first: %s, older completion: %s, sub-ms: %s)', async (terminalFirst, olderCompletion, subMillisecond) => { + const finished = { digest: 'step1', name: 'RUN test', hasLogs: false, completedAt: subMillisecond ? '2026-09-17T00:00:00.000000002Z' : '2026-09-17T00:00:01Z', error: 'exit 17' } + const running = { digest: 'step1', name: 'RUN test', hasLogs: true, ...(olderCompletion ? { completedAt: subMillisecond ? '2026-09-17T00:00:00.000000001Z' : '2026-09-17T00:00:00Z', error: 'older error' } : {}) } + const records = terminalFirst ? [finished, running] : [running, finished] + const api = apiWith(path => { + const q = new URL('http://local' + path).searchParams + if (!q.has('step')) return { ...base, steps: [records[q.has('cursor') ? 1 : 0]], ...(q.has('cursor') ? {} : { nextCursor: 'steps2' }) } + return { ...base, steps: [], entries: [entry('same\n'), entry('same\n')] } + }) + const snapshot = await readBuildLogs(api, 'p', 'archive', 'b') + expect(snapshot.steps).toEqual([{ ...finished, hasLogs: true }]) + expect(snapshot.output).toHaveLength(1) + expect(snapshot.output[0]!.entries).toEqual([entry('same\n'), entry('same\n')]) + expect(api.rawRequest).toHaveBeenCalledTimes(3) + }) + it('follows step and output pagination, preserving repeated records and API order', async () => { const api = apiWith((path) => { const q = new URL('http://local' + path).searchParams From 4e2c99ed8598181418a6b60bc6b85341b7ef4d37 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 17 Sep 2026 20:14:37 -0700 Subject: [PATCH 2/2] fix: clear stale errors after a later successful step --- src/build-logs.ts | 2 +- test/build-logs.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/build-logs.ts b/src/build-logs.ts index 3e6e202..e69c09e 100644 --- a/src/build-logs.ts +++ b/src/build-logs.ts @@ -74,7 +74,7 @@ export async function readBuildLogs(api: Api, projectId: string, source: BuildSo if (!previous) { byDigest.set(step.digest, step); continue } const [older, newer] = previous.completedAt && (!step.completedAt || completionKey(previous.completedAt) > completionKey(step.completedAt)) ? [step, previous] : [previous, step] - byDigest.set(step.digest, { ...older, ...newer, hasLogs: older.hasLogs || newer.hasLogs, error: newer.error || older.error }) + byDigest.set(step.digest, { ...older, ...newer, hasLogs: older.hasLogs || newer.hasLogs, error: newer.error }) } const steps = [...byDigest.values()] follow?.emit({ ...first, steps, output: [] }) diff --git a/test/build-logs.test.ts b/test/build-logs.test.ts index 54ae932..f1dbd6c 100644 --- a/test/build-logs.test.ts +++ b/test/build-logs.test.ts @@ -8,8 +8,8 @@ const entry = (message: string) => ({ timestamp: '2026-09-17T00:00:00Z', message const apiWith = (read: (path: string) => unknown): Pick => ({ rawRequest: vi.fn(async (_method, path) => ({ status: 200, body: read(path) })) }) describe('build logs', () => { - it.each([[false, false, false], [true, false, false], [false, true, false], [true, true, false], [false, true, true], [true, true, true]])('merges duplicate steps across pages (terminal first: %s, older completion: %s, sub-ms: %s)', async (terminalFirst, olderCompletion, subMillisecond) => { - const finished = { digest: 'step1', name: 'RUN test', hasLogs: false, completedAt: subMillisecond ? '2026-09-17T00:00:00.000000002Z' : '2026-09-17T00:00:01Z', error: 'exit 17' } + it.each([[false, false, false, false], [true, false, false, false], [false, true, false, false], [true, true, false, false], [false, true, true, false], [true, true, true, false], [false, true, true, true], [true, true, true, true]])('merges duplicate steps across pages (terminal first: %s, older completion: %s, sub-ms: %s, success: %s)', async (terminalFirst, olderCompletion, subMillisecond, success) => { + const finished = { digest: 'step1', name: 'RUN test', hasLogs: false, completedAt: subMillisecond ? '2026-09-17T00:00:00.000000002Z' : '2026-09-17T00:00:01Z', ...(success ? {} : { error: 'exit 17' }) } const running = { digest: 'step1', name: 'RUN test', hasLogs: true, ...(olderCompletion ? { completedAt: subMillisecond ? '2026-09-17T00:00:00.000000001Z' : '2026-09-17T00:00:00Z', error: 'older error' } : {}) } const records = terminalFirst ? [finished, running] : [running, finished] const api = apiWith(path => {