Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/build-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -21,6 +21,12 @@ type FollowState = { tails: Map<string, LogTail>; 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<BuildLogSnapshot> {
let bytes = 0
let requests = 0
Expand Down Expand Up @@ -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<string, BuildLogPage['steps'][number]>()
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 })
}
const steps = [...byDigest.values()]
follow?.emit({ ...first, steps, output: [] })
const output: BuildLogSnapshot['output'] = []
for (const step of steps) {
Expand Down
16 changes: 16 additions & 0 deletions test/build-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ const entry = (message: string) => ({ timestamp: '2026-09-17T00:00:00Z', message
const apiWith = (read: (path: string) => unknown): Pick<ApiClient, 'rawRequest'> => ({ rawRequest: vi.fn(async (_method, path) => ({ status: 200, body: read(path) })) })

describe('build logs', () => {
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 => {
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
Expand Down
Loading