/* RFC-0270: derives pipeline step timing budgets from locally observed telemetry instead of hand-guessed expectedDurationMs constants. A gitignored, append-only, size-capped local history (node_modules/.cache/site-kernel/telemetry/steps.ndjson) records every executed pipeline step's duration; pipeline.budget.generate aggregates it into the committed docs/pipeline-budgets.generated.yaml (p50/p95/expectedDurationMs per pipeline+command+app). The pipeline runner prefers a budget-file entry over the inline expectedDurationMs when both exist. Do not commit raw per-run telemetry — only the aggregated budget file is committed. Do not auto-tune timeoutMs (the hard kill limit) — only expected-duration budgets. Do not gate builds on budgets — they are advisory telemetry, never a failure. Budgets derive from observed telemetry, not guesses — the baseline is measured. RFC-0270: initial implementation. ADR-0023: add batchAppendStepTelemetry for batched telemetry writes at pipeline completion. RFC-0963: filter telemetry by registered sites from fleet/fleet.sites.yaml — removes stale entries for retired apps. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import { createHash } from "node:crypto"; import { dirname, join } from "node:path"; import { parse as yamlParse, stringify as yamlStringify } from "yaml"; import { buildGeneratedHeader } from "./generated-marker.ts"; import { writeFileAtomic } from "@warpgogol/werkstatt-shared/kernel"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-shared/kernel"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; const FLEET_SITES_RELATIVE_PATH = join("fleet", "fleet.sites.yaml"); export interface StepTelemetryRecord { pipeline: string; command: string; app: string | null; durationMs: number; timedOut: boolean; /** ISO timestamp — retained only in the local ndjson history, never in the committed budgets file. */ recordedAt: string; } export interface StepBudget { pipeline: string; command: string; app: string | null; sampleCount: number; p50Ms: number; p95Ms: number; expectedDurationMs: number; } export interface PipelineBudgetsFile { meta: { schemaVersion: 1; deterministic: true; generatedAt: null; historyHash: string; }; budgets: StepBudget[]; } const TELEMETRY_RELATIVE_PATH = join( "node_modules", ".cache", "werkstatt", "telemetry", "steps.ndjson", ); const BUDGETS_RELATIVE_PATH = join("docs", "pipeline-budgets.generated.yaml"); const MAX_HISTORY_BYTES = 5 * 1024 * 1024; function telemetryPath(workspaceRoot: string): string { return join(workspaceRoot, TELEMETRY_RELATIVE_PATH); } export function budgetsFilePath(workspaceRoot: string): string { return join(workspaceRoot, BUDGETS_RELATIVE_PATH); } /** * Best-effort append of one telemetry record to the local ndjson history. * Never throws — a telemetry write failure must never break a real pipeline * run. FIFO-caps the file at MAX_HISTORY_BYTES by dropping the oldest whole * lines once the cap would be exceeded. */ export async function appendStepTelemetry( workspaceRoot: string, record: StepTelemetryRecord, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (io.readonly) return; try { const filePath = telemetryPath(workspaceRoot); await io.mkdir(dirname(filePath)); const line = `${JSON.stringify(record)}\n`; let existing = ""; try { existing = await io.readFile(filePath); } catch { existing = ""; } let next = existing + line; if (Buffer.byteLength(next, "utf8") > MAX_HISTORY_BYTES) { const lines = next.split("\n").filter((l) => l.length > 0); while ( lines.length > 1 && Buffer.byteLength(lines.join("\n") + "\n", "utf8") > MAX_HISTORY_BYTES ) { lines.shift(); } next = lines.join("\n") + "\n"; } await io.writeFile(filePath, next); } catch { // Telemetry is best-effort; never let a persistence failure surface to the caller. } } /** * ADR-0023: Batch-append multiple telemetry records in a single read-modify-write * cycle. Replaces N per-step appendStepTelemetry calls (each doing its own * read+write) with a single read+write at pipeline completion. * * Never throws — a telemetry write failure must never break a real pipeline run. * FIFO-caps the file at MAX_HISTORY_BYTES by dropping the oldest whole lines * once the cap would be exceeded. */ export async function batchAppendStepTelemetry( workspaceRoot: string, records: StepTelemetryRecord[], io: WorkspaceIO = createDefaultIO().io, ): Promise { if (records.length === 0 || io.readonly) return; try { const filePath = telemetryPath(workspaceRoot); await io.mkdir(dirname(filePath)); const lines = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; let existing = ""; try { existing = await io.readFile(filePath); } catch { existing = ""; } let next = existing + lines; if (Buffer.byteLength(next, "utf8") > MAX_HISTORY_BYTES) { const allLines = next.split("\n").filter((l) => l.length > 0); while ( allLines.length > 1 && Buffer.byteLength(allLines.join("\n") + "\n", "utf8") > MAX_HISTORY_BYTES ) { allLines.shift(); } next = allLines.join("\n") + "\n"; } await io.writeFile(filePath, next); } catch { // Telemetry is best-effort; never let a persistence failure surface to the caller. } } /** Pure parser: skips unparseable lines and reports the skip count. */ export function parseTelemetryHistory(raw: string): { records: StepTelemetryRecord[]; skipped: number; } { const records: StepTelemetryRecord[] = []; let skipped = 0; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; try { const parsed = JSON.parse(trimmed) as Partial; if ( typeof parsed.pipeline === "string" && typeof parsed.command === "string" && (typeof parsed.app === "string" || parsed.app === null) && typeof parsed.durationMs === "number" && Number.isFinite(parsed.durationMs) ) { records.push({ pipeline: parsed.pipeline, command: parsed.command, app: parsed.app ?? null, durationMs: parsed.durationMs, timedOut: parsed.timedOut === true, recordedAt: typeof parsed.recordedAt === "string" ? parsed.recordedAt : "", }); } else { skipped++; } } catch { skipped++; } } return { records, skipped }; } function percentile(sortedAsc: number[], p: number): number { if (sortedAsc.length === 0) return 0; if (sortedAsc.length === 1) return sortedAsc[0]!; const index = Math.min(sortedAsc.length - 1, Math.ceil((p / 100) * sortedAsc.length) - 1); return sortedAsc[Math.max(0, index)]!; } /** * Pure aggregation over a set of telemetry records — deterministic for * identical input, sorted by (pipeline, command, app) for stable output. * expectedDurationMs = ceil(p95 * 1.5), rounded up to the nearest second. */ export function aggregateBudgets(records: StepTelemetryRecord[]): StepBudget[] { const groups = new Map< string, { pipeline: string; command: string; app: string | null; durations: number[] } >(); for (const record of records) { const key = `${record.pipeline}${record.command}${record.app ?? ""}`; const group = groups.get(key) ?? { pipeline: record.pipeline, command: record.command, app: record.app, durations: [], }; group.durations.push(record.durationMs); groups.set(key, group); } const budgets: StepBudget[] = []; for (const group of groups.values()) { const sorted = [...group.durations].sort((a, b) => a - b); const p50Ms = percentile(sorted, 50); const p95Ms = percentile(sorted, 95); const expectedDurationMs = Math.ceil((p95Ms * 1.5) / 1000) * 1000; budgets.push({ pipeline: group.pipeline, command: group.command, app: group.app, sampleCount: sorted.length, p50Ms, p95Ms, expectedDurationMs, }); } budgets.sort( (a, b) => a.pipeline.localeCompare(b.pipeline) || a.command.localeCompare(b.command) || (a.app ?? "").localeCompare(b.app ?? ""), ); return budgets; } function historyHash(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } /** Reads the committed budgets file. Returns null when absent or unparseable. */ export async function loadPipelineBudgets( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { const raw = await io.readFile(budgetsFilePath(workspaceRoot)); const parsed = yamlParse(raw) as PipelineBudgetsFile; if (!Array.isArray(parsed.budgets)) return null; return parsed; } catch { return null; } } /** * Runner lookup: an exact (pipeline, command, app) budget wins; falls back to * the app-agnostic (app: null) budget for the same pipeline+command; else * undefined (caller falls back to the inline expectedDurationMs). */ export function lookupExpectedDurationMs( budgets: PipelineBudgetsFile | null, pipeline: string, command: string, app: string | null, ): number | undefined { if (!budgets) return undefined; const exact = budgets.budgets.find( (b) => b.pipeline === pipeline && b.command === command && b.app === app, ); if (exact) return exact.expectedDurationMs; const generic = budgets.budgets.find( (b) => b.pipeline === pipeline && b.command === command && b.app === null, ); return generic?.expectedDurationMs; } export interface PipelineBudgetGenerateResult { command: "pipeline.budget.generate"; written: boolean; budgetCount: number; sampleCount: number; skippedLines: number; } /** * RFC-0963: Read the fleet site registry and return the set of registered site ids. * Returns null when the file is absent or unparseable — in that case, no filtering * is applied (backward compatible with pre-RFC-0963 behavior). */ async function loadRegisteredSiteIds( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise | null> { try { const raw = await io.readFile(join(workspaceRoot, FLEET_SITES_RELATIVE_PATH)); const parsed = yamlParse(raw) as { sites?: Array<{ id: string }> }; if (!Array.isArray(parsed.sites)) return null; return new Set(parsed.sites.map((s) => s.id)); } catch { return null; } } export async function runPipelineBudgetGenerate( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const dryRun = context.dryRun || input.flags["dry-run"] === true; let raw = ""; try { raw = await context.io.readFile(telemetryPath(context.workspaceRoot)); } catch { return { data: { command: "pipeline.budget.generate", written: false, budgetCount: 0, sampleCount: 0, skippedLines: 0, }, exitCode: 0, summary: "[pipeline.budget.generate] no local telemetry history found — nothing to aggregate", }; } const { records: allRecords, skipped } = parseTelemetryHistory(raw); if (allRecords.length === 0) { return { data: { command: "pipeline.budget.generate", written: false, budgetCount: 0, sampleCount: 0, skippedLines: skipped, }, exitCode: 0, summary: "[pipeline.budget.generate] telemetry history is empty — nothing to aggregate", }; } // RFC-0963: filter telemetry by registered sites to remove stale entries for retired apps. const registeredSites = await loadRegisteredSiteIds(context.workspaceRoot); let records = allRecords; let filteredCount = 0; if (registeredSites !== null) { records = allRecords.filter((r) => { // Keep workspace-scoped records (app === null) and records for registered sites. if (r.app === null) return true; const keep = registeredSites.has(r.app); if (!keep) filteredCount++; return keep; }); } if (records.length === 0 && filteredCount > 0) { // RFC-0963: write an empty budgets file to overwrite stale entries from retired apps. const emptyFile: PipelineBudgetsFile = { meta: { schemaVersion: 1, deterministic: true, generatedAt: null, historyHash: historyHash(raw), }, budgets: [], }; const emptyContent = `${buildGeneratedHeader({ filePath: BUDGETS_RELATIVE_PATH, ownerCommand: "pipeline.budget.generate" })}${yamlStringify(emptyFile)}\n`; if (!dryRun) { const outputPath = budgetsFilePath(context.workspaceRoot); await context.io.mkdir(dirname(outputPath)); await writeFileAtomic(outputPath, emptyContent); } return { data: { command: "pipeline.budget.generate", written: !dryRun, budgetCount: 0, sampleCount: 0, skippedLines: skipped, }, exitCode: 0, summary: `[pipeline.budget.generate] all telemetry records filtered out (${filteredCount} retired app(s) removed) — wrote empty budgets file`, }; } const budgets = aggregateBudgets(records); const file: PipelineBudgetsFile = { meta: { schemaVersion: 1, deterministic: true, generatedAt: null, historyHash: historyHash(raw), }, budgets, }; const content = `${buildGeneratedHeader({ filePath: BUDGETS_RELATIVE_PATH, ownerCommand: "pipeline.budget.generate" })}${yamlStringify(file)}\n`; if (!dryRun) { const outputPath = budgetsFilePath(context.workspaceRoot); await context.io.mkdir(dirname(outputPath)); await writeFileAtomic(outputPath, content); } const filterNote = filteredCount > 0 ? ` (${filteredCount} retired app record(s) filtered)` : ""; const summaryText = dryRun ? `[pipeline.budget.generate] dry-run — ${budgets.length} budget(s) from ${records.length} sample(s)${filterNote}` : `[pipeline.budget.generate] wrote ${budgets.length} budget(s) from ${records.length} sample(s)${filterNote}${skipped > 0 ? ` (${skipped} unparseable line(s) skipped)` : ""}`; return { data: { command: "pipeline.budget.generate", written: !dryRun, budgetCount: budgets.length, sampleCount: records.length, skippedLines: skipped, }, exitCode: 0, summary: summaryText, }; } // Re-exported for tests that want to assert against the real fs stat of the history file. export async function telemetryFileSizeBytes( workspaceRoot: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { const s = await io.stat(telemetryPath(workspaceRoot)); return s.size; } catch { return 0; } }