import { sanitizeText } from "./text.js"; import type { WorkflowProgressData, WorkflowTraceEvent, WorkflowUpdateRecord } from "./types.js"; import { validateProgressData } from "./updates.js"; export type ProgressConfidence = "low" | "medium" | "high"; export type ProgressEstimate = { key: string; data: WorkflowProgressData; sampleCount: number; delta?: number; rateLow?: number; rateMedian?: number; rateHigh?: number; remainingLowMs?: number; remainingMedianMs?: number; remainingHighMs?: number; confidence?: ProgressConfidence; sourceEstimatedFinishAt?: string; unavailableReason?: string; elapsedMs?: number; }; export type ProgressSample = { at: string; data: WorkflowProgressData; }; export type ProgressTrackState = { key: string; samples: ProgressSample[]; estimate: ProgressEstimate; }; const TERMINAL = new Set(["completed", "failed", "cancelled"]); export function estimateProgress( key: string, samples: ProgressSample[], now = new Date(), ): ProgressEstimate { if (samples.length === 0) throw new Error("progress estimation requires at least one sample"); for (const sample of samples) validateProgressData(sample.data as Record); const latest = samples.at(-1) as ProgressSample; const data = latest.data; const firstAt = Date.parse(samples[0]!.at); const endAt = TERMINAL.has(data.status) ? Date.parse(latest.at) : now.getTime(); const elapsedMs = Number.isFinite(firstAt) && Number.isFinite(endAt) ? Math.max(0, endAt - firstAt) : undefined; const base: ProgressEstimate = { key, data, sampleCount: 1, ...(elapsedMs !== undefined ? { elapsedMs } : {}), }; if (TERMINAL.has(data.status)) return base; const sourceFinish = validSourceFinish(latest, now); if (sourceFinish !== undefined) base.sourceEstimatedFinishAt = sourceFinish; if (data.status === "waiting" || data.status === "blocked") { return sourceFinish === undefined ? { ...base, unavailableReason: `progress is ${data.status}` } : base; } if (data.completed === undefined || data.total === undefined) { return sourceFinish === undefined ? { ...base, unavailableReason: "total is unknown" } : base; } const epoch = currentEpoch(samples); const intervals: Array<{ rate: number; delta: number }> = []; for (let index = Math.max(1, epoch.length - 8); index < epoch.length; index += 1) { const previous = epoch[index - 1] as ProgressSample; const current = epoch[index] as ProgressSample; const elapsedMs = Date.parse(current.at) - Date.parse(previous.at); const previousCompleted = previous.data.completed; const currentCompleted = current.data.completed; if (elapsedMs <= 0 || previousCompleted === undefined || currentCompleted === undefined) continue; intervals.push({ rate: (currentCompleted - previousCompleted) / elapsedMs, delta: currentCompleted - previousCompleted, }); } base.sampleCount = intervals.length + 1; const latestDelta = intervals.at(-1)?.delta; if (latestDelta !== undefined) base.delta = latestDelta; if (intervals.length === 0) { return sourceFinish === undefined ? { ...base, unavailableReason: "needs another progress sample" } : base; } const rates = intervals.map((item) => item.rate).sort((a, b) => a - b); const median = quantile(rates, 0.5); const p25 = quantile(rates, 0.25); const p75 = quantile(rates, 0.75); const remaining = Math.max(0, data.total - data.completed); const positive = rates.filter((rate) => rate > 0); if (median <= 0 || positive.length === 0) { return sourceFinish === undefined ? { ...base, unavailableReason: "no positive progress rate" } : base; } const spread = (p75 - p25) / median; const confidence: ProgressConfidence = intervals.length >= 5 ? spread <= 0.25 ? "high" : spread <= 0.5 ? "medium" : "low" : intervals.length >= 2 && spread <= 0.5 ? "medium" : "low"; const slow = p25 > 0 ? p25 : undefined; const fast = p75 > 0 ? p75 : median; return { ...base, ...(slow !== undefined ? { rateLow: slow } : {}), rateMedian: median, rateHigh: fast, remainingLowMs: remaining / fast, remainingMedianMs: remaining / median, ...(slow !== undefined ? { remainingHighMs: remaining / slow } : {}), confidence, }; } export function progressRecordsFromTrace(events: WorkflowTraceEvent[]): WorkflowUpdateRecord[] { const records: WorkflowUpdateRecord[] = []; for (const event of events) { if ( event.type !== "update_published" || event.nodeId === undefined || event.attemptId === undefined ) continue; const payload = event.payload; if ( typeof payload.updateId !== "string" || typeof payload.type !== "string" || typeof payload.key !== "string" || payload.data === null || typeof payload.data !== "object" || Array.isArray(payload.data) ) continue; records.push({ updateId: payload.updateId, seq: event.seq, at: event.at, runId: event.runId, nodeId: event.nodeId, attemptId: event.attemptId, type: payload.type, key: payload.key, data: payload.data as Record, }); } return records; } export function appendProgressHistory( history: WorkflowUpdateRecord[], additions: WorkflowUpdateRecord[], maxPerTrack = 9, maxRecords = 576, ): WorkflowUpdateRecord[] { const retained: WorkflowUpdateRecord[] = []; const counts = new Map(); const combined = [...history, ...additions]; for (let index = combined.length - 1; index >= 0 && retained.length < maxRecords; index -= 1) { const record = combined[index] as WorkflowUpdateRecord; if (record.type !== "progress") continue; const count = counts.get(record.key) ?? 0; if (count >= maxPerTrack) continue; counts.set(record.key, count + 1); retained.push(record); } return retained.reverse(); } export function progressTracksFromRecords( records: WorkflowUpdateRecord[], now = new Date(), ): ProgressTrackState[] { const grouped = new Map(); for (const record of records) { if (record.type !== "progress") continue; try { const data = validateProgressData(record.data); const samples = grouped.get(record.key) ?? []; samples.push({ at: record.at, data }); grouped.set(record.key, samples); } catch { // Readers skip malformed historical update data instead of failing. } } return [...grouped.entries()].map(([key, samples]) => ({ key, samples, estimate: estimateProgress(key, samples, now), })); } export function formatProgressLine(estimate: ProgressEstimate, now = new Date()): string { const { data } = estimate; const label = sanitizeText(data.label ?? estimate.key); const unit = data.unit === undefined ? "" : sanitizeText(data.unit); const count = data.completed === undefined ? data.status : data.total === undefined ? `${formatNumber(data.completed)} ${unit}`.trim() : `${formatNumber(data.completed)}/${formatNumber(data.total)} ${unit}`.trim(); let eta = ""; if (estimate.sourceEstimatedFinishAt !== undefined) { eta = `source ETA ${formatRemaining(Date.parse(estimate.sourceEstimatedFinishAt) - now.getTime())}`; } else if (estimate.remainingMedianMs !== undefined) { const range = estimate.remainingLowMs !== undefined && estimate.remainingHighMs !== undefined && Math.abs(estimate.remainingHighMs - estimate.remainingLowMs) >= 1_000 ? `${formatRemaining(estimate.remainingLowMs)}–${formatRemaining(estimate.remainingHighMs)}` : formatRemaining(estimate.remainingMedianMs); eta = `ETA ${range}`; } else if (!TERMINAL.has(data.status) && data.phase === undefined) { eta = `ETA unavailable${estimate.unavailableReason ? ` (${estimate.unavailableReason})` : ""}`; } const phase = data.phase === undefined ? "" : sanitizeText(data.phase); const elapsed = data.phase === undefined || estimate.elapsedMs === undefined ? "" : `elapsed ${formatRemaining(estimate.elapsedMs)}`; return [label, count, phase, elapsed, eta].filter(Boolean).join(" "); } export function formatProgressReport( estimates: ProgressEstimate[], nextCheckMinutes?: number, now = new Date(), maxChars = 4_000, ): string { const footer = nextCheckMinutes === undefined ? undefined : `Next check: ${nextCheckMinutes} min`; const lines: string[] = []; let omitted = 0; for (const estimate of prioritizeProgressEstimates(estimates)) { const block = [`Progress: ${formatProgressLine(estimate, now)}`]; if (estimate.rateMedian !== undefined && estimate.data.unit !== undefined) { const median = estimate.rateMedian * 60_000; const low = estimate.rateLow === undefined ? undefined : estimate.rateLow * 60_000; const high = estimate.rateHigh === undefined ? undefined : estimate.rateHigh * 60_000; const rate = low !== undefined && high !== undefined && Math.abs(high - low) >= 0.01 ? `${formatNumber(low)}–${formatNumber(high)}` : formatNumber(median); block.push(`Rate: ${rate} ${estimate.data.unit}/min`); block.push( `Estimate: ${estimate.confidence ?? "low"} confidence, ${estimate.sampleCount} samples`, ); } const candidate = [...lines, ...block, ...(footer === undefined ? [] : [footer])].join("\n"); if (candidate.length > maxChars) { omitted += 1; continue; } lines.push(...block); } if (omitted > 0) { const marker = `${omitted} progress track${omitted === 1 ? "" : "s"} omitted.`; if ( [...lines, marker, ...(footer === undefined ? [] : [footer])].join("\n").length <= maxChars ) { lines.push(marker); } } if (footer !== undefined && [...lines, footer].join("\n").length <= maxChars) lines.push(footer); return lines.join("\n").slice(0, maxChars); } export function formatRemaining(ms: number): string { const value = Math.ceil(Math.max(0, ms) / 1_000) * 1_000; if (value < 60_000) return `${Math.ceil(value / 1_000)}s`; if (value < 3_600_000) return `${Math.ceil(value / 60_000)}m`; if (value < 86_400_000) return `${(value / 3_600_000).toFixed(value < 36_000_000 ? 1 : 0)}h`; return `${(value / 86_400_000).toFixed(1)}d`; } function currentEpoch(samples: ProgressSample[]): ProgressSample[] { const epoch: ProgressSample[] = []; for (const sample of samples) { const prior = epoch.at(-1); if (prior !== undefined && resetsEpoch(prior.data, sample.data)) epoch.length = 0; epoch.push(sample); } return epoch; } function resetsEpoch(previous: WorkflowProgressData, next: WorkflowProgressData): boolean { return ( previous.phase !== next.phase || previous.unit !== next.unit || previous.total !== next.total || (previous.completed !== undefined && next.completed !== undefined && next.completed < previous.completed) || (TERMINAL.has(previous.status) && !TERMINAL.has(next.status)) ); } function validSourceFinish(sample: ProgressSample, now: Date): string | undefined { const finish = sample.data.sourceEstimatedFinishAt; if (finish === undefined) return undefined; const finishMs = Date.parse(finish); const sourceMs = Date.parse(sample.data.sourceUpdatedAt ?? sample.at); return finishMs > sourceMs && finishMs > now.getTime() ? finish : undefined; } function quantile(sorted: number[], p: number): number { if (sorted.length === 1) return sorted[0] as number; const position = (sorted.length - 1) * p; const lower = Math.floor(position); const fraction = position - lower; const a = sorted[lower] as number; const b = sorted[Math.min(lower + 1, sorted.length - 1)] as number; return a + (b - a) * fraction; } export function prioritizeProgressEstimates(estimates: ProgressEstimate[]): ProgressEstimate[] { const weight = (item: ProgressEstimate) => item.key === "overall" ? -3 : item.data.status === "failed" || item.data.status === "blocked" ? -2 : item.data.status === "waiting" ? -1 : 0; return [...estimates].sort((a, b) => weight(a) - weight(b)); } function formatNumber(value: number): string { return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(value); }