1// Action results ledger - records timing and results for ep actions 2// Composable: testdeploy phases contribute to individual action stats 3import { readFileSync, appendFileSync, existsSync } from 'fs' 4import { execSync } from 'child_process' 13export const ledgerPath = `${ptTmpDir}/action-results.jsonl` 15// Actions that deploy to k8s (testdeploy is compound: includes apply phase) 16export const deployActions = ['apply', 'testdeploy'] as const 17export type DeployAction = typeof deployActions[number] 18export const isDeployAction = (action: string): action is DeployAction => 19 deployActions.includes(action as DeployAction) 21export const getGitShaShort = (): string => { 23 return execSync('git rev-parse --short HEAD', { cwd: ptDir, encoding: 'utf8' }).trim() 27export const getGitShaFull = (): string => headShaSnapshot || '' 29export const currentBuilder = (): string => { 31 const base = dockName === 'nix' ? 'nix' : dockName === 'kanikojob' ? 'kaniko' : dockName === 'podman' ? 'podman' : 'docker' 32 const flags: string[] = [] 33 if (cliFlag('--withTurbo')) flags.push('turbo') 34 if (cliFlag('--withStandalone')) flags.push('standalone') 35 return flags.length ? `${base}+${flags.join('+')}` : base 38export type ActionResult = { 48 phases?: Record<string, number> 49 suites?: Record<string, {passed: boolean, message: string, durationSec?: number}> 50 flags?: Record<string, boolean> 54export type TimingStats = { 60export type TimingOpts = { suites?: string[], ptenv?: Ptenv } 62const durationStats = (durations: number[]): TimingStats | null => { 63 if (!durations.length) return null 64 const sum = durations.reduce((a, b) => a + b, 0) 66 avgSec: Math.round(sum / durations.length), 67 maxSec: Math.round(Math.max(...durations)), 68 samples: durations.length 74export const readLedger = (): ActionResult[] => { 76 if (!existsSync(ledgerPath)) return [] 77 return readFileSync(ledgerPath, 'utf8') 81 .map(l => JSON.parse(l)) 85export const recordActionResult = (result: ActionResult) => { 87 appendFileSync(ledgerPath, JSON.stringify(result) + '\n') 90const filterByBuilder = (records: ActionResult[]) => { 91 const b = currentBuilder() 92 return records.filter(r => (r.builder || 'docker') === b) 95export const getActionTimingStats = (ep: string, action: string, opts?: TimingOpts): TimingStats | null => { 97 let records = filterByBuilder(readLedger().filter(r => r.ep === epRel && r.action === action)) 99 if (opts?.ptenv) records = records.filter(r => r.ptenv === opts.ptenv) 101 // If specific suites requested, sum per-suite max timings (3C approach) 102 if (opts?.suites?.length) { 103 const suiteDurations: Record<string, number[]> = Object.fromEntries(opts.suites.map(s => [s, []])) 104 for (const r of records) { 105 for (const suite of opts.suites) { 106 const sd = r.suites?.[suite]?.durationSec 107 if (sd) suiteDurations[suite].push(sd) 111 const hasPerSuiteData = opts.suites.some(s => suiteDurations[s].length > 0) 112 if (hasPerSuiteData) { 113 const totalMax = opts.suites.reduce((sum, s) => sum + (suiteDurations[s].length ? Math.max(...suiteDurations[s]) : 0), 0) 114 const totalAvg = opts.suites.reduce((sum, s) => { 115 const d = suiteDurations[s] 116 return sum + (d.length ? d.reduce((a, b) => a + b, 0) / d.length : 0) 118 return { avgSec: Math.round(totalAvg), maxSec: Math.round(totalMax), samples: records.length } 122 // Fall back to total duration (also handles apply action via phases) 123 const directDurations = records.filter(r => r.durationSec).map(r => r.durationSec) 124 const phaseDurations = filterByBuilder(readLedger().filter(r => r.ep === epRel && r.phases?.[action])) 125 .map(r => r.phases![action]) 126 return durationStats([...directDurations, ...phaseDurations]) 129export const fmtDuration = (sec: number, approx = true): string => { 130 const prefix = approx ? '~' : '' 131 if (sec < 60) return `${prefix}${Math.round(sec)}s` 132 return `${prefix}${Math.round(sec / 60)}m` 135export const fmtAgo = (isoTs: string): string => { 136 const agoMs = Date.now() - new Date(isoTs).getTime() 137 if (agoMs < 60_000) return `${Math.round(agoMs / 1000)}s` 138 if (agoMs < 3600_000) return `${Math.round(agoMs / 60_000)}m` 139 if (agoMs < 86400_000) return `${(agoMs / 3600_000).toFixed(1)}h` 140 return `${Math.round(agoMs / 86400_000)}d` 143export const getTimingHint = (ep: string, action: string, opts?: TimingOpts): string => { 144 const stats = getActionTimingStats(ep, action, opts) 145 return stats ? `${fmtDuration(stats.maxSec)} eta` : '? eta' 148export const getTestdeployTimingHint = (ep: string): string => { 150 const records = filterByBuilder(readLedger().filter(r => r.ep === epRel && r.action === 'testdeploy' && r.phases)) 151 if (!records.length) return '? eta' 153 const phaseMax = (phase: string) => { 154 const vals = records.map(r => r.phases?.[phase]).filter((v): v is number => v != null) 155 return vals.length ? Math.max(...vals) : 0 158 const local = phaseMax('runtests_local'), apply = phaseMax('apply'), deployed = phaseMax('runtests_deployed') 159 const total = local + apply + deployed 160 return `${fmtDuration(local)} local + ${fmtDuration(apply)} apply + ${fmtDuration(deployed)} deployed (${fmtDuration(total)} total)` 163export const printActionEta = (ep: string, action: string) => { 164 const stats = getActionTimingStats(ep, action) 165 if (stats && stats.maxSec >= 10) console.log(`${action}: ${getTimingHint(ep, action)}`) 168export const printTestdeployEta = (ep: string) => { 169 console.log(`testdeploy: ${getTestdeployTimingHint(ep)}`) 172export const recordApplyTiming = ({ep, startTime, gitSha, buildSha, imageReused}: {ep: string, startTime: number, gitSha: string, buildSha?: string, imageReused?: boolean}) => { 173 if (!ep || !gitSha) return 174 const builder = currentBuilder() 178 ts: new Date().toISOString(), 179 durationSec: Math.round((Date.now() - startTime) / 1000), 182 ...(buildSha && buildSha !== gitSha && {buildSha}), 183 ...(imageReused && {imageReused}), 184 ...(builder !== 'docker' && {builder}),