chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""Code migration agent — deterministic recipes + agent-loop fallback scaffold.
|
||||
|
||||
The hard architectural primitive is the two-layer structure: deterministic
|
||||
recipe pass first (fast, auditable, safe), then agent loop for remaining
|
||||
failures with a hard budget and a failure-classification step that feeds a
|
||||
taxonomy dashboard. This scaffold implements both layers and runs a
|
||||
50-repo simulation with mixed outcomes.
|
||||
|
||||
Run: python main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# repo + failure taxonomy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAILURE_CLASSES = [
|
||||
"dep_upgrade_required",
|
||||
"build_tool_drift",
|
||||
"custom_annotation",
|
||||
"test_flake",
|
||||
"syntax_edge_case",
|
||||
"budget_exhausted",
|
||||
"coverage_regression",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Repo:
|
||||
name: str
|
||||
loc: int
|
||||
lang: str # "java" | "python"
|
||||
hardness: float # 0..1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attempt:
|
||||
repo: Repo
|
||||
recipe_applied: int = 0
|
||||
agent_turns: int = 0
|
||||
cost_usd: float = 0.0
|
||||
wall_min: float = 0.0
|
||||
status: str = "pending" # "pass" | "fail"
|
||||
failure_class: str | None = None
|
||||
coverage_base: float = 80.0
|
||||
coverage_final: float = 80.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deterministic recipe pass -- OpenRewrite / libcst stand-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_recipes(repo: Repo) -> int:
|
||||
"""Returns number of rewrites applied."""
|
||||
base = 20 + int(repo.loc / 500)
|
||||
return int(base * (1 - 0.2 * repo.hardness))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent loop -- classify failure, apply fix, retry; budget-aware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUDGET_MIN = 30.0
|
||||
BUDGET_USD = 8.0
|
||||
BUDGET_TURNS = 20
|
||||
|
||||
|
||||
def agent_loop(attempt: Attempt, rng: random.Random) -> None:
|
||||
"""Simulates the plan-act loop until pass or budget exhaustion."""
|
||||
# cost per turn drifts with hardness
|
||||
per_turn_min = 2.8 + attempt.repo.hardness * 2.0
|
||||
per_turn_usd = 0.45 + attempt.repo.hardness * 0.65
|
||||
|
||||
# probability of passing per turn depends on hardness (0.02-0.18)
|
||||
turn_pass_p = max(0.02, 0.22 * (1 - attempt.repo.hardness * 0.95))
|
||||
|
||||
while True:
|
||||
if attempt.agent_turns >= BUDGET_TURNS:
|
||||
attempt.status = "fail"
|
||||
attempt.failure_class = "budget_exhausted"
|
||||
return
|
||||
if attempt.wall_min >= BUDGET_MIN or attempt.cost_usd >= BUDGET_USD:
|
||||
attempt.status = "fail"
|
||||
attempt.failure_class = "budget_exhausted"
|
||||
return
|
||||
|
||||
attempt.agent_turns += 1
|
||||
attempt.wall_min += per_turn_min
|
||||
attempt.cost_usd += per_turn_usd
|
||||
|
||||
if rng.random() < turn_pass_p:
|
||||
# coverage check
|
||||
delta = rng.gauss(0.0, 0.6)
|
||||
attempt.coverage_final = attempt.coverage_base + delta
|
||||
if attempt.coverage_final < attempt.coverage_base - 2.0:
|
||||
attempt.status = "fail"
|
||||
attempt.failure_class = "coverage_regression"
|
||||
return
|
||||
attempt.status = "pass"
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classification of stuck repos -- bucket into taxonomy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify_failure(rng: random.Random) -> str:
|
||||
"""Stand-in for the agent's failure classifier. Real implementation
|
||||
reads build logs and test output."""
|
||||
weights = {
|
||||
"dep_upgrade_required": 0.30,
|
||||
"build_tool_drift": 0.20,
|
||||
"custom_annotation": 0.18,
|
||||
"test_flake": 0.15,
|
||||
"syntax_edge_case": 0.17,
|
||||
}
|
||||
r = rng.random()
|
||||
acc = 0.0
|
||||
for cls, w in weights.items():
|
||||
acc += w
|
||||
if r <= acc:
|
||||
return cls
|
||||
return "syntax_edge_case"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pipeline -- recipes then agent then PR/file outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def migrate(repo: Repo, rng: random.Random) -> Attempt:
|
||||
attempt = Attempt(repo=repo)
|
||||
attempt.recipe_applied = run_recipes(repo)
|
||||
|
||||
# easy repos often go straight to pass after recipes
|
||||
straight_through_p = 0.55 * (1 - repo.hardness)
|
||||
if rng.random() < straight_through_p:
|
||||
delta = rng.gauss(0.0, 0.4)
|
||||
attempt.coverage_final = attempt.coverage_base + delta
|
||||
attempt.status = "pass"
|
||||
attempt.wall_min = 3.0 + rng.random() * 4
|
||||
attempt.cost_usd = 0.30
|
||||
return attempt
|
||||
|
||||
# otherwise run the agent loop
|
||||
agent_loop(attempt, rng)
|
||||
|
||||
if attempt.status == "fail" and attempt.failure_class == "budget_exhausted":
|
||||
# classify root cause of why the budget was exhausted
|
||||
if rng.random() < 0.75:
|
||||
attempt.failure_class = classify_failure(rng)
|
||||
return attempt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 50-repo simulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def synth_bench(rng: random.Random) -> list[Repo]:
|
||||
bench: list[Repo] = []
|
||||
for i in range(50):
|
||||
lang = "java" if rng.random() < 0.6 else "python"
|
||||
hardness = min(0.95, max(0.05, rng.gauss(0.65, 0.18)))
|
||||
bench.append(Repo(name=f"repo-{i:02d}-{lang}",
|
||||
loc=rng.randint(800, 40_000),
|
||||
lang=lang,
|
||||
hardness=hardness))
|
||||
return bench
|
||||
|
||||
|
||||
def main() -> None:
|
||||
rng = random.Random(19)
|
||||
bench = synth_bench(rng)
|
||||
|
||||
results: list[Attempt] = []
|
||||
for repo in bench:
|
||||
results.append(migrate(repo, rng))
|
||||
|
||||
passed = [a for a in results if a.status == "pass"]
|
||||
failed = [a for a in results if a.status == "fail"]
|
||||
|
||||
print(f"=== migration-bench run (50 repos) ===")
|
||||
print(f"passed : {len(passed):2d} ({len(passed) / 50:.1%})")
|
||||
print(f"failed : {len(failed):2d}")
|
||||
|
||||
print("\nfailure taxonomy:")
|
||||
taxonomy: dict[str, int] = {}
|
||||
for a in failed:
|
||||
taxonomy[a.failure_class or "unknown"] = taxonomy.get(a.failure_class or "unknown", 0) + 1
|
||||
for cls, n in sorted(taxonomy.items(), key=lambda x: -x[1]):
|
||||
print(f" {cls:24s} {n}")
|
||||
|
||||
if passed:
|
||||
mean_cost = sum(a.cost_usd for a in passed) / len(passed)
|
||||
mean_min = sum(a.wall_min for a in passed) / len(passed)
|
||||
mean_turns = sum(a.agent_turns for a in passed) / len(passed)
|
||||
mean_cov_delta = sum(a.coverage_final - a.coverage_base for a in passed) / len(passed)
|
||||
print("\npass-set metrics:")
|
||||
print(f" mean $/repo : ${mean_cost:.2f}")
|
||||
print(f" mean wall min : {mean_min:.1f}")
|
||||
print(f" mean agent turns: {mean_turns:.1f}")
|
||||
print(f" mean cov delta : {mean_cov_delta:+.2f} points")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
# Code migration agent dashboard (TypeScript skeleton)
|
||||
|
||||
Multi-file TypeScript skeleton for the dashboard layer of the code migration
|
||||
agent capstone. The agent (Python) runs in a sandbox; this server renders
|
||||
progress for the operator.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/index.ts` — entry point, simulates ticks and optionally serves HTTP.
|
||||
- `src/server.ts` — Hono routes for `/`, `/dashboard`, `/migrations`, `/migrations/:id`.
|
||||
- `src/migrations.ts` — per-file state machine and seed data.
|
||||
- `src/cost.ts` — turn count and dollar budget enforcement.
|
||||
- `src/types.ts` — shared types.
|
||||
- `tests/*.test.ts` — `node --test` style tests via `tsx`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm start # offline: simulate 40 ticks and print rollup
|
||||
npm run serve # serve the HTML dashboard on PORT (default 8009)
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
## Spec references
|
||||
|
||||
- Source lesson: `phases/19-capstone-projects/09-code-migration-agent/docs/en.md`
|
||||
- Recipes: [OpenRewrite](https://docs.openrewrite.org), libcst.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "code-migration-agent-dashboard",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-file TypeScript skeleton for the code migration agent dashboard.",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"serve": "SERVE=1 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Migration } from "./types.js";
|
||||
|
||||
export const MAX_TURNS = 20;
|
||||
export const BUDGET_USD = 8;
|
||||
|
||||
export function turnCostUsd(rng: () => number = Math.random): number {
|
||||
return Number((0.06 + rng() * 0.18).toFixed(3));
|
||||
}
|
||||
|
||||
export type BudgetVerdict = {
|
||||
exhausted: boolean;
|
||||
reason?: "turns" | "cost";
|
||||
};
|
||||
|
||||
export function checkBudget(m: Migration): BudgetVerdict {
|
||||
if (m.turns >= m.maxTurns) {
|
||||
return { exhausted: true, reason: "turns" };
|
||||
}
|
||||
if (m.spentUsd >= m.budgetUsd) {
|
||||
return { exhausted: true, reason: "cost" };
|
||||
}
|
||||
return { exhausted: false };
|
||||
}
|
||||
|
||||
export function chargeTurn(m: Migration, rng: () => number = Math.random): void {
|
||||
m.turns += 1;
|
||||
m.spentUsd = Number((m.spentUsd + turnCostUsd(rng)).toFixed(3));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Code Migration Agent: dashboard skeleton entry point (TypeScript).
|
||||
*
|
||||
* Mirrors the dashboard layer from docs/en.md: agent runs in a sandbox; this
|
||||
* server renders progress for the operator. Hono routes serve HTML root,
|
||||
* /migrations, and /migrations/:id. State machine in migrations.ts; budget
|
||||
* + cost in cost.ts; types in types.ts.
|
||||
*
|
||||
* Source: phases/19-capstone-projects/09-code-migration-agent/docs/en.md
|
||||
* Recipe specs: https://docs.openrewrite.org and the libcst Python parser.
|
||||
*/
|
||||
|
||||
import { serve } from "@hono/node-server";
|
||||
import { buildApp } from "./server.js";
|
||||
import { defaultSeed, rolledUpStats, tickAll } from "./migrations.js";
|
||||
|
||||
function summarise(migrations: ReturnType<typeof defaultSeed>): void {
|
||||
const stats = rolledUpStats(migrations);
|
||||
console.log("[dashboard] migrations seeded:", migrations.length);
|
||||
for (const m of migrations) {
|
||||
const passed = m.files.filter((f) => f.status === "passed").length;
|
||||
console.log(
|
||||
`[dashboard] ${m.repo} ${m.sourceRuntime}->${m.targetRuntime} ` +
|
||||
`state=${m.state} files=${passed}/${m.files.length} ` +
|
||||
`turns=${m.turns}/${m.maxTurns} cost=$${m.spentUsd.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
console.log("[dashboard] roll-up:", stats);
|
||||
}
|
||||
|
||||
export function runDemoTicks(rounds: number): ReturnType<typeof defaultSeed> {
|
||||
const migrations = defaultSeed();
|
||||
for (let i = 0; i < rounds; i++) tickAll(migrations);
|
||||
return migrations;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log("[dashboard] simulating 40 ticks of agent progress...");
|
||||
const migrations = runDemoTicks(40);
|
||||
summarise(migrations);
|
||||
if (process.env["SERVE"] === "1") {
|
||||
const port = Number(process.env["PORT"] ?? 8009);
|
||||
const app = buildApp(migrations);
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`[dashboard] serving on http://localhost:${info.port}`);
|
||||
});
|
||||
setInterval(() => tickAll(migrations), 750).unref();
|
||||
} else {
|
||||
console.log(
|
||||
"[dashboard] set SERVE=1 to start the HTTP dashboard on PORT (default 8009)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,145 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { BUDGET_USD, MAX_TURNS, chargeTurn, checkBudget } from "./cost.js";
|
||||
import type {
|
||||
FileDiff,
|
||||
FileStatus,
|
||||
Migration,
|
||||
Recipe,
|
||||
RolledUpStats,
|
||||
} from "./types.js";
|
||||
|
||||
const STATE_ORDER: FileStatus[] = [
|
||||
"queued",
|
||||
"rewriting",
|
||||
"building",
|
||||
"passed",
|
||||
];
|
||||
|
||||
export function fileDiff(
|
||||
path: string,
|
||||
recipe: Recipe,
|
||||
status: FileStatus = "queued",
|
||||
): FileDiff {
|
||||
return {
|
||||
path,
|
||||
status,
|
||||
recipe,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
testsTouched: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function seedMigration(
|
||||
repo: string,
|
||||
sourceRuntime: string,
|
||||
targetRuntime: string,
|
||||
files: FileDiff[],
|
||||
): Migration {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
repo,
|
||||
sourceRuntime,
|
||||
targetRuntime,
|
||||
startedAt: Date.now(),
|
||||
budgetUsd: BUDGET_USD,
|
||||
spentUsd: 0,
|
||||
turns: 0,
|
||||
maxTurns: MAX_TURNS,
|
||||
files,
|
||||
state: "running",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultSeed(): Migration[] {
|
||||
return [
|
||||
seedMigration("acme/payments-svc", "java-8", "java-17", [
|
||||
fileDiff("pom.xml", "openrewrite"),
|
||||
fileDiff("src/main/java/Payments.java", "openrewrite"),
|
||||
fileDiff("src/main/java/Refunds.java", "openrewrite"),
|
||||
fileDiff("src/test/java/PaymentsTest.java", "agent"),
|
||||
]),
|
||||
seedMigration("acme/billing-py", "python-2.7", "python-3.12", [
|
||||
fileDiff("setup.py", "libcst"),
|
||||
fileDiff("billing/core.py", "libcst"),
|
||||
fileDiff("billing/dunning.py", "agent"),
|
||||
fileDiff("tests/test_core.py", "libcst"),
|
||||
]),
|
||||
seedMigration("acme/checkout-svc", "java-8", "java-17", [
|
||||
fileDiff("build.gradle", "openrewrite"),
|
||||
fileDiff("src/main/java/Checkout.java", "openrewrite"),
|
||||
fileDiff("src/main/java/Discount.java", "agent"),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function advanceFile(file: FileDiff, rng: () => number = Math.random): void {
|
||||
if (file.status === "passed" || file.status === "failed") return;
|
||||
const idx = STATE_ORDER.indexOf(file.status);
|
||||
const next = STATE_ORDER[idx + 1];
|
||||
if (!next) return;
|
||||
file.status = next;
|
||||
if (next === "rewriting") {
|
||||
file.linesAdded = 4 + Math.floor(rng() * 24);
|
||||
file.linesRemoved = 1 + Math.floor(rng() * 14);
|
||||
}
|
||||
if (next === "building" && rng() < 0.15) {
|
||||
file.status = "failed";
|
||||
file.lastError =
|
||||
"compile error: cannot find symbol javax.annotation.Nullable";
|
||||
}
|
||||
if (next === "passed" && file.path.includes("test")) {
|
||||
file.testsTouched = 2 + Math.floor(rng() * 6);
|
||||
}
|
||||
}
|
||||
|
||||
export function migrationDone(m: Migration): boolean {
|
||||
return m.files.every((f) => f.status === "passed" || f.status === "failed");
|
||||
}
|
||||
|
||||
export function tickOne(m: Migration, rng: () => number = Math.random): void {
|
||||
if (m.state !== "running") return;
|
||||
const inFlight = m.files.find(
|
||||
(f) => f.status !== "passed" && f.status !== "failed",
|
||||
);
|
||||
if (!inFlight) {
|
||||
m.state = m.files.some((f) => f.status === "failed") ? "failed" : "passed";
|
||||
return;
|
||||
}
|
||||
advanceFile(inFlight, rng);
|
||||
chargeTurn(m, rng);
|
||||
const verdict = checkBudget(m);
|
||||
if (verdict.exhausted) {
|
||||
m.state = "failed";
|
||||
return;
|
||||
}
|
||||
if (migrationDone(m)) {
|
||||
m.state = m.files.some((f) => f.status === "failed") ? "failed" : "passed";
|
||||
}
|
||||
}
|
||||
|
||||
export function tickAll(migrations: Migration[], rng: () => number = Math.random): void {
|
||||
for (const m of migrations) {
|
||||
tickOne(m, rng);
|
||||
}
|
||||
}
|
||||
|
||||
export function rolledUpStats(migrations: Migration[]): RolledUpStats {
|
||||
let running = 0;
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let spent = 0;
|
||||
for (const m of migrations) {
|
||||
if (m.state === "running") running++;
|
||||
if (m.state === "passed") passed++;
|
||||
if (m.state === "failed") failed++;
|
||||
spent += m.spentUsd;
|
||||
}
|
||||
return {
|
||||
total: migrations.length,
|
||||
running,
|
||||
passed,
|
||||
failed,
|
||||
spentUsd: Number(spent.toFixed(3)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Hono } from "hono";
|
||||
import { rolledUpStats } from "./migrations.js";
|
||||
import type { Migration } from "./types.js";
|
||||
|
||||
export function buildApp(migrations: Migration[]): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => c.html(renderDashboardHtml(migrations)));
|
||||
app.get("/dashboard", (c) => c.html(renderDashboardHtml(migrations)));
|
||||
|
||||
app.get("/migrations", (c) =>
|
||||
c.json({
|
||||
stats: rolledUpStats(migrations),
|
||||
migrations: migrations.map((m) => ({
|
||||
id: m.id,
|
||||
repo: m.repo,
|
||||
state: m.state,
|
||||
sourceRuntime: m.sourceRuntime,
|
||||
targetRuntime: m.targetRuntime,
|
||||
turns: m.turns,
|
||||
spentUsd: m.spentUsd,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/migrations/:id", (c) => {
|
||||
const id = c.req.param("id");
|
||||
const m = migrations.find((x) => x.id === id);
|
||||
if (!m) return c.json({ error: "not_found", id }, 404);
|
||||
return c.json(m);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export function renderDashboardHtml(migrations: Migration[]): string {
|
||||
const stats = rolledUpStats(migrations);
|
||||
const rows = migrations
|
||||
.map((m) => {
|
||||
const passedFiles = m.files.filter((f) => f.status === "passed").length;
|
||||
const pct = m.files.length === 0 ? 0 : Math.round((passedFiles / m.files.length) * 100);
|
||||
return [
|
||||
"<tr>",
|
||||
`<td><a href="/migrations/${m.id}">${m.repo}</a></td>`,
|
||||
`<td>${m.sourceRuntime} to ${m.targetRuntime}</td>`,
|
||||
`<td>${m.state}</td>`,
|
||||
`<td>${pct}%</td>`,
|
||||
`<td>${m.turns}/${m.maxTurns}</td>`,
|
||||
`<td>$${m.spentUsd.toFixed(2)}/$${m.budgetUsd}</td>`,
|
||||
"</tr>",
|
||||
].join("");
|
||||
})
|
||||
.join("\n");
|
||||
return [
|
||||
"<!doctype html>",
|
||||
"<html><head><title>Code migration dashboard</title>",
|
||||
"<style>",
|
||||
"body{font-family:system-ui,sans-serif;margin:2rem;max-width:960px;}",
|
||||
"table{border-collapse:collapse;width:100%;}",
|
||||
"th,td{padding:.4rem .8rem;border-bottom:1px solid #ddd;text-align:left;}",
|
||||
"th{background:#f3f3f3;}",
|
||||
".stats{display:flex;gap:1.5rem;margin-bottom:1rem;}",
|
||||
".stat{background:#fafafa;border:1px solid #ddd;padding:.6rem 1rem;border-radius:6px;}",
|
||||
"</style></head><body>",
|
||||
"<h1>Code migration dashboard</h1>",
|
||||
"<div class='stats'>",
|
||||
`<div class='stat'><b>${stats.total}</b> migrations</div>`,
|
||||
`<div class='stat'>${stats.running} running</div>`,
|
||||
`<div class='stat'>${stats.passed} passed</div>`,
|
||||
`<div class='stat'>${stats.failed} failed</div>`,
|
||||
`<div class='stat'>$${stats.spentUsd.toFixed(2)} spent</div>`,
|
||||
"</div>",
|
||||
"<table><thead><tr>",
|
||||
"<th>repo</th><th>migration</th><th>state</th><th>progress</th><th>turns</th><th>cost</th>",
|
||||
"</tr></thead><tbody>",
|
||||
rows,
|
||||
"</tbody></table>",
|
||||
"<p><small>Auto-refreshes every 2s. Endpoints: /migrations, /migrations/:id.</small></p>",
|
||||
"<script>setTimeout(()=>location.reload(),2000)</script>",
|
||||
"</body></html>",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type FileStatus =
|
||||
| "queued"
|
||||
| "rewriting"
|
||||
| "building"
|
||||
| "passed"
|
||||
| "failed";
|
||||
|
||||
export type Recipe = "openrewrite" | "libcst" | "agent";
|
||||
|
||||
export type FileDiff = {
|
||||
path: string;
|
||||
status: FileStatus;
|
||||
recipe: Recipe;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
testsTouched: number;
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
export type MigrationState = "running" | "passed" | "failed" | "queued";
|
||||
|
||||
export type Migration = {
|
||||
id: string;
|
||||
repo: string;
|
||||
sourceRuntime: string;
|
||||
targetRuntime: string;
|
||||
startedAt: number;
|
||||
budgetUsd: number;
|
||||
spentUsd: number;
|
||||
turns: number;
|
||||
maxTurns: number;
|
||||
files: FileDiff[];
|
||||
state: MigrationState;
|
||||
};
|
||||
|
||||
export type RolledUpStats = {
|
||||
total: number;
|
||||
running: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
spentUsd: number;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { BUDGET_USD, MAX_TURNS, chargeTurn, checkBudget } from "../src/cost.js";
|
||||
import { defaultSeed } from "../src/migrations.js";
|
||||
|
||||
test("checkBudget returns clean when fresh", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
const v = checkBudget(m);
|
||||
assert.equal(v.exhausted, false);
|
||||
});
|
||||
|
||||
test("checkBudget flags turns exhausted", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
m.turns = MAX_TURNS;
|
||||
const v = checkBudget(m);
|
||||
assert.equal(v.exhausted, true);
|
||||
assert.equal(v.reason, "turns");
|
||||
});
|
||||
|
||||
test("checkBudget flags cost exhausted", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
m.spentUsd = BUDGET_USD;
|
||||
const v = checkBudget(m);
|
||||
assert.equal(v.exhausted, true);
|
||||
assert.equal(v.reason, "cost");
|
||||
});
|
||||
|
||||
test("chargeTurn increments turns and adds cost", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
chargeTurn(m, () => 0.5);
|
||||
assert.equal(m.turns, 1);
|
||||
assert.ok(m.spentUsd > 0);
|
||||
assert.ok(m.spentUsd < BUDGET_USD);
|
||||
});
|
||||
|
||||
test("chargeTurn upper bound stays inside budget per turn", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
for (let i = 0; i < MAX_TURNS; i++) chargeTurn(m, () => 1);
|
||||
assert.equal(m.turns, MAX_TURNS);
|
||||
assert.ok(m.spentUsd <= BUDGET_USD, `spent ${m.spentUsd} exceeds budget ${BUDGET_USD}`);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
advanceFile,
|
||||
defaultSeed,
|
||||
fileDiff,
|
||||
migrationDone,
|
||||
rolledUpStats,
|
||||
tickOne,
|
||||
} from "../src/migrations.js";
|
||||
|
||||
test("seed produces three running migrations", () => {
|
||||
const migrations = defaultSeed();
|
||||
assert.equal(migrations.length, 3);
|
||||
for (const m of migrations) {
|
||||
assert.equal(m.state, "running");
|
||||
assert.ok(m.files.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("advanceFile walks queued to rewriting to building to passed", () => {
|
||||
const f = fileDiff("foo.java", "openrewrite");
|
||||
const noFail = () => 0.99;
|
||||
advanceFile(f, noFail);
|
||||
assert.equal(f.status, "rewriting");
|
||||
advanceFile(f, noFail);
|
||||
assert.equal(f.status, "building");
|
||||
advanceFile(f, noFail);
|
||||
assert.equal(f.status, "passed");
|
||||
});
|
||||
|
||||
test("advanceFile is a no-op on terminal states", () => {
|
||||
const f = fileDiff("foo.java", "openrewrite");
|
||||
f.status = "passed";
|
||||
advanceFile(f);
|
||||
assert.equal(f.status, "passed");
|
||||
f.status = "failed";
|
||||
advanceFile(f);
|
||||
assert.equal(f.status, "failed");
|
||||
});
|
||||
|
||||
test("tickOne can move a migration to passed when all files pass", () => {
|
||||
const m = defaultSeed()[0]!;
|
||||
const det = () => 0.99;
|
||||
for (let i = 0; i < 200; i++) tickOne(m, det);
|
||||
assert.equal(migrationDone(m), true);
|
||||
assert.ok(m.state === "passed" || m.state === "failed");
|
||||
});
|
||||
|
||||
test("rolledUpStats counts states correctly", () => {
|
||||
const m = defaultSeed();
|
||||
m[0]!.state = "passed";
|
||||
m[1]!.state = "failed";
|
||||
const stats = rolledUpStats(m);
|
||||
assert.equal(stats.passed, 1);
|
||||
assert.equal(stats.failed, 1);
|
||||
assert.equal(stats.running, 1);
|
||||
assert.equal(stats.total, 3);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user