Skip to content
Merged
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
302 changes: 302 additions & 0 deletions tools/budget/daily-cost-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
#!/usr/bin/env bun
// daily-cost-report.ts — daily cost-monitoring entry point.
//
// TypeScript+Bun port of daily-cost-report.sh, slice 18 of the
// TS+Bun migration. Wraps snapshot-burn.sh + project-runway.sh and
// writes the human-readable projection to docs/budget-history/
// latest-report.md. Designed to be the single entry point for the
// daily /schedule remote-trigger routine (task #287 visibility
// surface for the human maintainer).
//
// Usage:
// bun tools/budget/daily-cost-report.ts # full run, writes report
// bun tools/budget/daily-cost-report.ts --dry-run # snapshot dry-run
// bun tools/budget/daily-cost-report.ts --skip-snapshot # only regenerate report
//
// Exit codes:
// 0 success
// 1 if any wrapped step fails (snapshot-burn or project-runway)
// 2 on CLI-argument errors
//
// Composes with:
// - tools/budget/snapshot-burn.sh (data-capture primitive — bash;
// TS port `snapshot-burn.ts` landed in #894 but this wrapper still
// spawns the .sh during the soak period)
// - tools/budget/project-runway.sh (projection primitive; bash-only)
// - docs/budget-history/snapshots.jsonl (append-only data store)
// - docs/budget-history/latest-report.md (visibility surface;
// OVERWRITTEN each run, not append-only)
//
// Note: this wrapper spawns the bash siblings (snapshot-burn.sh,
// project-runway.sh) NOT the TS port — the bash versions are the
// soak-period reference until they retire. Once project-runway is
// also TS-ported and the cluster soaks clean, this wrapper can
// switch to spawning the .ts versions.

import { statSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

const SPAWN_MAX_BUFFER = 64 * 1024 * 1024;

interface ParsedArgs {
readonly dryRun: boolean;
readonly skipSnapshot: boolean;
}

interface ArgError {
readonly error: string;
readonly exitCode: 2;
}

function parseArgs(argv: readonly string[]): ParsedArgs | ArgError | { readonly help: true } {
let dryRun = false;
let skipSnapshot = false;
for (const a of argv) {
if (a === "--dry-run") dryRun = true;
else if (a === "--skip-snapshot") skipSnapshot = true;
else if (a === "-h" || a === "--help") return { help: true };
else return { error: `error: unknown argument '${a}'`, exitCode: 2 };
}
return { dryRun, skipSnapshot };
}

function emitHelp(): void {
process.stdout.write(
`daily-cost-report.ts — daily cost-monitoring entry point.\n` +
`\n` +
`Usage:\n` +
` bun tools/budget/daily-cost-report.ts # full run, writes report\n` +
` bun tools/budget/daily-cost-report.ts --dry-run # snapshot dry-run\n` +
` bun tools/budget/daily-cost-report.ts --skip-snapshot # only regenerate report\n`,
);
}

function scriptDir(): string {
return dirname(fileURLToPath(import.meta.url));
}

function repoRoot(): string {
return resolve(scriptDir(), "..", "..");
}

function nowIsoUtc(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}

function gitHeadSha(root: string): string {
// eslint-disable-next-line sonarjs/no-os-command-from-path
const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
encoding: "utf8",
maxBuffer: SPAWN_MAX_BUFFER,
});
return result.status === 0 ? result.stdout.trim() : "unknown";
}

interface SpawnError {
readonly code?: string;
}

interface ChildOutcome {
readonly status: number;
readonly note: string;
}

function classifySpawnFailure(
status: number | null,
signal: string | null,
error: SpawnError | undefined,
): ChildOutcome {
// 4-case helper (status set / ENOENT / signal / other) reused from
// PRs #887, #898, #900. Distinguishes ENOENT/permission/signal from
// a normal non-zero exit so callers see the actual failure mode.
if (status !== null) return { status, note: "" };
if (error?.code === "ENOENT") {
return { status: 127, note: "command not found on PATH (ENOENT)" };
}
if (error?.code !== undefined) {
return { status: 1, note: `spawn failed (${error.code})` };
}
if (signal !== null) {
return { status: 1, note: `terminated by signal ${signal}` };
}
return { status: 1, note: "terminated without exit code" };
}

function runSnapshotBurn(dryRun: boolean): { exitCode: number; note: string } {
const args = dryRun ? ["--dry-run"] : [];
const argsSuffix = args.length > 0 ? ` ${args.join(" ")}` : "";
process.stdout.write(`==> snapshot-burn.sh${argsSuffix}\n`);

const path = resolve(scriptDir(), "snapshot-burn.sh");
const result = spawnSync(path, args, {
stdio: "inherit",
maxBuffer: SPAWN_MAX_BUFFER,
});
const classified = classifySpawnFailure(
result.status,
result.signal,
result.error as SpawnError | undefined,
);
return { exitCode: classified.status, note: classified.note };
}
Comment thread
AceHack marked this conversation as resolved.

function runProjectRunway(): { exitCode: number; output: string; note: string } {
const path = resolve(scriptDir(), "project-runway.sh");
// Capture combined stdout+stderr in-order via shell-side `2>&1`
// (matches bash original `$("$script_dir/project-runway.sh" 2>&1)`
// which preserves chronological interleaving). Concatenating
// `result.stdout + result.stderr` would reorder warnings vs
// success output (Copilot P1 on #901).
// The path is constructed from import.meta.url + dirname, not from
// user input, so shell-quoting safety isn't an issue here.
const result = spawnSync("/bin/bash", ["-c", `"${path}" 2>&1`], {
encoding: "utf8",
maxBuffer: SPAWN_MAX_BUFFER,
stdio: ["inherit", "pipe", "pipe"],
});
// Defensive: result.stdout can be null when the child fails to
// start (Copilot P0 on #901). Guard with `?? ""`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const output = result.stdout ?? "";
const classified = classifySpawnFailure(
result.status,
result.signal,
result.error as SpawnError | undefined,
);
return {
exitCode: classified.status,
output,
note: classified.note,
};
Comment thread
AceHack marked this conversation as resolved.
}

function buildReport(args: { ts: string; gitSha: string; projection: string }): string {
return `# Latest cost projection — auto-generated

**Generated:** \`${args.ts}\`
**Factory git SHA:** \`${args.gitSha}\`
**Source:** \`tools/budget/daily-cost-report.ts\` (wraps snapshot-burn.sh + project-runway.sh)

This file is **OVERWRITTEN** on each daily run. Historical snapshots live in
\`docs/budget-history/snapshots.jsonl\` (append-only); historical projections
can be reconstructed from any snapshot subset via \`tools/budget/project-runway.sh\`.

---

## Projection text

\`\`\`text
${args.projection}
\`\`\`

---

## How to read this

- **\`Actions billable_ms cumulative\`** — cumulative GitHub-Actions billable runtime across captured snapshots. On public repos this is typically 0 (included minutes); meaningful for macOS / private-repo / Enterprise-plan accounts.
- **\`Per-PR Actions ms (naive)\`** — rolling-window estimate of per-merged-PR Actions cost. Caveats in the projection text below; treat as proxy until \`N \\geq 3\` cumulative snapshots exist.
- **\`Actions fit\`** — whether projected Stages 1-4 burn fits the configured free-tier allowance. If \`EXCEEDS\`, the gate-conditions section names escape valves.
- **\`Copilot projected USD\`** — assumed-30-day span at the current seat count and rate. Re-run with \`--copilot-rate\` to model rate changes.

---

## Source data

- Snapshots: \`docs/budget-history/snapshots.jsonl\`
- Methodology: \`docs/budget-history/README.md\`
- Wrapper: \`tools/budget/daily-cost-report.ts\` (this run)
- Capture script: \`tools/budget/snapshot-burn.sh\`
- Projection script: \`tools/budget/project-runway.sh\`
`;
}

function isRegularFileSafe(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}

interface StepResult {
readonly ok: boolean;
readonly projection?: string;
}

function runSnapshotStep(skipSnapshot: boolean, dryRun: boolean): StepResult {
if (skipSnapshot) {
process.stdout.write("==> snapshot-burn.sh SKIPPED per --skip-snapshot\n");
return { ok: true };
}
const burn = runSnapshotBurn(dryRun);
if (burn.exitCode !== 0) {
if (burn.note.length > 0) {
process.stderr.write(`snapshot-burn.sh: ${burn.note}\n`);
}
process.stderr.write(`error: snapshot-burn.sh failed (exit ${String(burn.exitCode)})\n`);
return { ok: false };
}
return { ok: true };
}

const BOOTSTRAP_PROJECTION =
"No snapshots captured yet. The first snapshot-burn.sh run will append a baseline row to docs/budget-history/snapshots.jsonl. Once N >= 2 snapshots exist across LFG merges, projection becomes available.";

function runProjectionStep(snapshotsPath: string): StepResult {
if (!isRegularFileSafe(snapshotsPath)) {
process.stdout.write(
"==> project-runway.sh SKIPPED (no snapshots yet); writing bootstrap report\n",
);
return { ok: true, projection: BOOTSTRAP_PROJECTION };
}
process.stdout.write("==> project-runway.sh\n");
const runway = runProjectRunway();
if (runway.exitCode !== 0) {
if (runway.note.length > 0) {
process.stderr.write(`project-runway.sh: ${runway.note}\n`);
}
process.stderr.write(`error: project-runway.sh failed (exit ${String(runway.exitCode)})\n`);
return { ok: false };
}
return { ok: true, projection: runway.output };
}

export function main(argv: readonly string[]): number {
const parsed = parseArgs(argv);
if ("help" in parsed) {
emitHelp();
return 0;
}
if ("error" in parsed) {
process.stderr.write(`${parsed.error}\n`);
return parsed.exitCode;
}

const root = repoRoot();
const reportPath = resolve(root, "docs", "budget-history", "latest-report.md");
const snapshotsPath = resolve(root, "docs", "budget-history", "snapshots.jsonl");

const snapshotStep = runSnapshotStep(parsed.skipSnapshot, parsed.dryRun);
if (!snapshotStep.ok) return 1;

const projectionStep = runProjectionStep(snapshotsPath);
if (!projectionStep.ok) return 1;
const projection = projectionStep.projection ?? "";

// Step 3 — write the report (overwrite, not append)
const report = buildReport({
ts: nowIsoUtc(),
gitSha: gitHeadSha(root),
projection,
});
writeFileSync(reportPath, report);
process.stdout.write(`==> wrote ${reportPath}\n`);
process.stdout.write("OK: daily cost report regenerated\n");
return 0;
}

if (import.meta.main) {
process.exit(main(process.argv.slice(2)));
}
Loading