🌳
pt0/deployF/testsF/actionResultsAI.mts
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'
11import type { Ptenv } from '../../sharedF/ptenvF.mts'
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 => {
22 try {
23 return execSync('git rev-parse --short HEAD', { cwd: ptDir, encoding: 'utf8' }).trim()
24 } catch { return '' }
27export const getGitShaFull = (): string => headShaSnapshot || ''
29export const currentBuilder = (): string => {
30 const dockName = getDockName()
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 = {
39 ep: string
40 action: string
41 ts: string
42 durationSec: number
43 success: boolean
44 gitSha?: string
45 buildSha?: string
46 imageReused?: boolean
47 ptenv?: string
48 phases?: Record<string, number>
49 suites?: Record<string, {passed: boolean, message: string, durationSec?: number}>
50 flags?: Record<string, boolean>
51 builder?: string
54export type TimingStats = {
55 avgSec: number
56 maxSec: number
57 samples: number
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)
65 return {
66 avgSec: Math.round(sum / durations.length),
67 maxSec: Math.round(Math.max(...durations)),
68 samples: durations.length
69 }
74export const readLedger = (): ActionResult[] => {
75 try {
76 if (!existsSync(ledgerPath)) return []
77 return readFileSync(ledgerPath, 'utf8')
78 .trim()
79 .split('\n')
80 .filter(Boolean)
81 .map(l => JSON.parse(l))
82 } catch { return [] }
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 => {
96 const epRel = toPtRelPath(ep)
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)
108 }
109 }
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)
117 }, 0)
118 return { avgSec: Math.round(totalAvg), maxSec: Math.round(totalMax), samples: records.length }
119 }
120 }
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 => {
149 const epRel = toPtRelPath(ep)
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
156 }
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()
175 recordActionResult({
176 ep: toPtRelPath(ep),
177 action: 'apply',
178 ts: new Date().toISOString(),
179 durationSec: Math.round((Date.now() - startTime) / 1000),
180 success: true,
181 gitSha,
182 ...(buildSha && buildSha !== gitSha && {buildSha}),
183 ...(imageReused && {imageReused}),
184 ...(builder !== 'docker' && {builder}),
185 })