-
Notifications
You must be signed in to change notification settings - Fork 1
ts(B-0086): port 1 budget script (.sh→.ts) — slice 18 of TS/Bun migration #901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AceHack
merged 5 commits into
main
from
lane-b/ts-bun-slice-18-daily-cost-report-2026-04-30
Apr 30, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7bcae92
ts(B-0086): port 1 budget script (.sh→.ts) — slice 18 of TS/Bun migra…
AceHack 9d8bdf8
review(slice-18): use statSync.isFile() instead of existsSync per Cod…
AceHack 787214e
review(slice-18): address Copilot P0+P0+P2 — spawn classification + n…
AceHack 4a13e32
review(slice-18) round-2: extract step-helpers + suppress no-unnecess…
AceHack 42b5c41
review(slice-18): preserve stdout/stderr ordering via shell-side 2>&1…
AceHack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } | ||
|
|
||
| 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, | ||
| }; | ||
|
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))); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.