aztrx-cli 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -4
- package/dist/cli.js +61 -1
- package/dist/core/browser.js +47 -0
- package/dist/core/classifier.js +92 -2
- package/dist/core/diagnose.js +91 -0
- package/dist/core/diff.js +137 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/index.js +7 -5
- package/dist/core/heal/llm.js +16 -0
- package/dist/core/heal/redact.js +5 -1
- package/dist/core/heal/sandbox.js +5 -1
- package/dist/core/httpFuzzer.js +8 -4
- package/dist/core/orchestrator.js +33 -11
- package/dist/core/patrol/loop.js +218 -0
- package/dist/core/patrol/pr.js +233 -0
- package/dist/core/patrol/record.js +103 -0
- package/dist/core/patrol/state.js +78 -0
- package/dist/core/renderMarkdown.js +92 -0
- package/dist/core/replay.js +2 -2
- package/dist/core/report.js +6 -3
- package/dist/core/resolver.js +86 -25
- package/dist/core/summarize.js +2 -2
- package/dist/core/swarm.js +38 -18
- package/dist/core/ui.js +1 -0
- package/dist/ui/app.js +27 -2
- package/media/repro-demo/crash.gif +0 -0
- package/media/repro-demo/crash.png +0 -0
- package/media/repro-demo/frame_0.png +0 -0
- package/media/repro-demo/frame_1.png +0 -0
- package/package.json +6 -2
package/dist/core/heal/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as fs from "fs";
|
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
import { redact, unredact } from "./redact.js";
|
|
21
21
|
import { auditPatch } from "./gates.js";
|
|
22
|
-
import { generatePatch, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
|
|
22
|
+
import { generatePatch, generateRulePatch, modelTiers, RULE_TIER, BudgetExhaustedError } from "./llm.js";
|
|
23
23
|
import { hasLlmKey } from "../llm.js";
|
|
24
24
|
import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
|
|
25
25
|
import { bootServer, detectStartCommand } from "./boot.js";
|
|
@@ -154,12 +154,14 @@ export async function heal(finding, opts) {
|
|
|
154
154
|
// 2. Generate (this tier).
|
|
155
155
|
let patch;
|
|
156
156
|
try {
|
|
157
|
-
patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn });
|
|
157
|
+
patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn, budget: opts.budget });
|
|
158
158
|
}
|
|
159
159
|
catch (e) {
|
|
160
|
-
// A
|
|
161
|
-
//
|
|
162
|
-
last =
|
|
160
|
+
// A spent session budget stops everything paid; a transport/config failure
|
|
161
|
+
// won't be fixed by a pricier tier either, so both break out here.
|
|
162
|
+
last = e instanceof BudgetExhaustedError
|
|
163
|
+
? { ...base, status: "budget-exhausted", error: e.message, model: tier.model }
|
|
164
|
+
: { ...base, status: "no-llm", error: e.message, model: tier.model };
|
|
163
165
|
break;
|
|
164
166
|
}
|
|
165
167
|
if (patch.hunks.length === 0) {
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -45,6 +45,14 @@ Hard rules:
|
|
|
45
45
|
- Do NOT add any new import/require/import(). Do NOT use eval or new Function. Do NOT write an empty catch block (catch {}). Do NOT touch child_process, exec, spawn, fork, process.exit.
|
|
46
46
|
- If you see __AZTRX_REDACTED_N__ placeholders, treat them as opaque tokens and carry them through unchanged — do not invent values for them.
|
|
47
47
|
- If you cannot fix the bug, return { "explanation": "cannot fix", "edits": [] }.`;
|
|
48
|
+
/** Thrown when the shared session budget has no paid generations left. Heal
|
|
49
|
+
* maps this to a `budget-exhausted` status rather than a transport error. */
|
|
50
|
+
export class BudgetExhaustedError extends Error {
|
|
51
|
+
constructor() {
|
|
52
|
+
super("spend budget exhausted");
|
|
53
|
+
this.name = "BudgetExhaustedError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
function buildPrompt(ctx) {
|
|
49
57
|
const loc = ctx.finding.mappedLocation;
|
|
50
58
|
const msg = redact(ctx.finding.rawMessage).text;
|
|
@@ -120,6 +128,12 @@ export async function generatePatch(ctx, opts = {}) {
|
|
|
120
128
|
return rulePatch;
|
|
121
129
|
throw new Error("no rule-based fix applicable");
|
|
122
130
|
}
|
|
131
|
+
// Paid path — enforce the shared session budget before spending, and charge it
|
|
132
|
+
// on success. The free rule tier above never reaches this, so a spent budget
|
|
133
|
+
// still lets free fixes through.
|
|
134
|
+
if (opts.budget && opts.budget.remaining <= 0) {
|
|
135
|
+
throw new BudgetExhaustedError();
|
|
136
|
+
}
|
|
123
137
|
const text = await complete({
|
|
124
138
|
system: SYSTEM,
|
|
125
139
|
prompt: buildPrompt(ctx),
|
|
@@ -127,5 +141,7 @@ export async function generatePatch(ctx, opts = {}) {
|
|
|
127
141
|
maxTokens: 2048,
|
|
128
142
|
temperature: 0,
|
|
129
143
|
});
|
|
144
|
+
if (opts.budget)
|
|
145
|
+
opts.budget.remaining -= 1;
|
|
130
146
|
return parsePatch(text);
|
|
131
147
|
}
|
package/dist/core/heal/redact.js
CHANGED
|
@@ -42,7 +42,11 @@ const VALUE_MATCH = [
|
|
|
42
42
|
label: "secret_value",
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
|
-
|
|
45
|
+
// Captures the password as group 2 and stops at `@` with a lookahead so the
|
|
46
|
+
// `@` is never consumed. (The old form captured `(@)` instead — group 2 was
|
|
47
|
+
// the literal `@` — so the password was dropped and `unredact` restored `@`
|
|
48
|
+
// in its place, corrupting the URL.)
|
|
49
|
+
re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)([^@\s]+)(?=@)/gi,
|
|
46
50
|
label: "url_password",
|
|
47
51
|
},
|
|
48
52
|
];
|
|
@@ -43,7 +43,11 @@ export function applyHunks(content, hunks) {
|
|
|
43
43
|
return { ok: false, patched: content, applied: 0, errors };
|
|
44
44
|
let patched = content;
|
|
45
45
|
for (const h of hunks) {
|
|
46
|
-
|
|
46
|
+
// Function replacer: a plain `replace(search, replace)` treats `$&`, `$1`,
|
|
47
|
+
// `` $` ``, `$'`, `$$` in `replace` as substitution tokens, so a fix that
|
|
48
|
+
// introduces a literal `$5` (a price, a template fragment, a regex capture)
|
|
49
|
+
// would be silently mangled. The function form inserts `replace` verbatim.
|
|
50
|
+
patched = patched.replace(h.search, () => h.replace);
|
|
47
51
|
}
|
|
48
52
|
return { ok: true, patched, applied: hunks.length, errors: [] };
|
|
49
53
|
}
|
package/dist/core/httpFuzzer.js
CHANGED
|
@@ -90,7 +90,7 @@ function hostAllowed(url, allowHosts) {
|
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
/** Harvest candidate endpoints the app actually uses — not blind probing. */
|
|
93
|
-
async function collectEndpoints(page, origin) {
|
|
93
|
+
async function collectEndpoints(page, origin, allowDestructive, seedUrls = []) {
|
|
94
94
|
const seen = new Map();
|
|
95
95
|
const push = (raw) => {
|
|
96
96
|
let u;
|
|
@@ -104,11 +104,15 @@ async function collectEndpoints(page, origin) {
|
|
|
104
104
|
return;
|
|
105
105
|
if (STATIC_EXT.test(u.pathname))
|
|
106
106
|
return;
|
|
107
|
-
if (DESTRUCTIVE_PATH.test(u.pathname))
|
|
107
|
+
if (!allowDestructive && DESTRUCTIVE_PATH.test(u.pathname))
|
|
108
108
|
return;
|
|
109
109
|
if (!seen.has(u.pathname))
|
|
110
110
|
seen.set(u.pathname, u);
|
|
111
111
|
};
|
|
112
|
+
// Endpoints observed live by the caller — e.g. a `fetch()` fired from a click
|
|
113
|
+
// handler that never appears in `performance` resources or the DOM.
|
|
114
|
+
for (const s of seedUrls)
|
|
115
|
+
push(s);
|
|
112
116
|
// URLs the page already fetched (API calls, RSC/data endpoints).
|
|
113
117
|
const resources = await page
|
|
114
118
|
.evaluate(() => performance.getEntriesByType("resource").map((e) => e.name))
|
|
@@ -191,11 +195,11 @@ export async function httpFuzz(page, targetUrl, bus, opts = {}) {
|
|
|
191
195
|
const allowHosts = opts.allowHosts ?? new Set();
|
|
192
196
|
if (!hostAllowed(targetUrl, allowHosts))
|
|
193
197
|
return 0;
|
|
194
|
-
if (!opts.dryRun) {
|
|
198
|
+
if (!opts.dryRun && opts.navigate !== false) {
|
|
195
199
|
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => { });
|
|
196
200
|
await page.waitForTimeout(500);
|
|
197
201
|
}
|
|
198
|
-
const endpoints = await collectEndpoints(page, new URL(targetUrl).origin);
|
|
202
|
+
const endpoints = await collectEndpoints(page, new URL(targetUrl).origin, Boolean(opts.allowDestructive), opts.seedUrls ?? []);
|
|
199
203
|
endpoints.sort((a, b) => a.pathname.localeCompare(b.pathname));
|
|
200
204
|
let sent = 0;
|
|
201
205
|
outer: for (const endpoint of endpoints) {
|
|
@@ -3,6 +3,7 @@ import pc from "picocolors";
|
|
|
3
3
|
import { VERSION } from "./version.js";
|
|
4
4
|
import { EventBus } from "./eventBus.js";
|
|
5
5
|
import { loadBaseline } from "./classifier.js";
|
|
6
|
+
import { diagnoseFinding } from "./diagnose.js";
|
|
6
7
|
import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
|
|
7
8
|
import { swarmDetect } from "./swarm.js";
|
|
8
9
|
import { ReplayEngine } from "./replay.js";
|
|
@@ -14,18 +15,23 @@ import { RunLog } from "./events.js";
|
|
|
14
15
|
import { heal } from "./heal/index.js";
|
|
15
16
|
import { submitTelemetry } from "./telemetry/index.js";
|
|
16
17
|
import { submitRun } from "./cloud/index.js";
|
|
18
|
+
import { formatDiff } from "./diff.js";
|
|
17
19
|
const SEVERITY_MARK = {
|
|
18
20
|
crash: pc.red("● crash "),
|
|
19
21
|
error: pc.red("● error "),
|
|
20
22
|
warning: pc.yellow("○ warning "),
|
|
21
23
|
noise: pc.dim("○ noise "),
|
|
22
24
|
};
|
|
23
|
-
function printFinding(f, write) {
|
|
25
|
+
function printFinding(f, write, lang) {
|
|
24
26
|
write(SEVERITY_MARK[f.severity] + pc.bold(f.rawMessage));
|
|
25
27
|
if (f.mappedLocation) {
|
|
26
28
|
write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
|
|
27
29
|
write(pc.dim(f.mappedLocation.codeContext));
|
|
28
30
|
}
|
|
31
|
+
// F14 — the one-line "why + fix" diagnosis, inline with every crash/error.
|
|
32
|
+
const dx = diagnoseFinding(f, lang);
|
|
33
|
+
if (dx)
|
|
34
|
+
write(pc.cyan(` ↳ ${dx}`));
|
|
29
35
|
if (f.serverError) {
|
|
30
36
|
write(pc.dim(` server: ${f.serverError.message}`));
|
|
31
37
|
}
|
|
@@ -78,6 +84,10 @@ export async function run(options) {
|
|
|
78
84
|
say(pc.dim(`Net: deny-by-default → allow ${[...allowHosts].join(", ") || "origin"}`));
|
|
79
85
|
if (options.storageState)
|
|
80
86
|
say(pc.dim(`Auth: ${options.storageState}`));
|
|
87
|
+
if (options.allowDestructive) {
|
|
88
|
+
say(pc.yellow(pc.bold("⚠ DESTRUCTIVE MODE — delete/pay/logout/checkout controls are ENABLED.")));
|
|
89
|
+
say(pc.yellow(" This can mutate real data. Run only against a disposable/dev instance you own."));
|
|
90
|
+
}
|
|
81
91
|
say("");
|
|
82
92
|
emitPhase("launch", url);
|
|
83
93
|
const runLog = new RunLog(repoRoot);
|
|
@@ -85,10 +95,11 @@ export async function run(options) {
|
|
|
85
95
|
runLog.append({ type: "run_start", url, ts: Date.now() });
|
|
86
96
|
const baseline = await loadBaseline(repoRoot);
|
|
87
97
|
const workers = options.workers ?? 1;
|
|
88
|
-
// F-swarm — parallel detection. One worker is the legacy pass;
|
|
89
|
-
//
|
|
98
|
+
// F-swarm — parallel detection. One worker is the legacy pass; `workers > 1`
|
|
99
|
+
// fans out into a swarm. `--http-fuzz` folds into the walk/fuzz pass as a
|
|
100
|
+
// post-pass on the same page (no extra worker). Findings come back merged by
|
|
90
101
|
// fingerprint, with per-worker action history already attached.
|
|
91
|
-
if (workers > 1
|
|
102
|
+
if (workers > 1) {
|
|
92
103
|
emitPhase("swarm", `${workers} worker(s)`);
|
|
93
104
|
}
|
|
94
105
|
else if (options.fuzz) {
|
|
@@ -105,6 +116,7 @@ export async function run(options) {
|
|
|
105
116
|
fuzz: options.fuzz,
|
|
106
117
|
httpFuzz: options.httpFuzz,
|
|
107
118
|
httpFuzzMutations: options.httpFuzzMutations,
|
|
119
|
+
allowDestructive: options.allowDestructive,
|
|
108
120
|
seed: options.seed ?? 42,
|
|
109
121
|
workers,
|
|
110
122
|
allowHosts,
|
|
@@ -125,7 +137,7 @@ export async function run(options) {
|
|
|
125
137
|
for (const f of findings) {
|
|
126
138
|
bus.emit("finding", f);
|
|
127
139
|
runLog.append({ type: "finding", finding: f });
|
|
128
|
-
printFinding(f, say);
|
|
140
|
+
printFinding(f, say, options.lang);
|
|
129
141
|
}
|
|
130
142
|
if (workerCount > 1) {
|
|
131
143
|
say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
|
|
@@ -235,7 +247,9 @@ export async function run(options) {
|
|
|
235
247
|
f.type !== "network_timeout" &&
|
|
236
248
|
f.repro &&
|
|
237
249
|
f.repro.verdict !== "unreliable");
|
|
238
|
-
const
|
|
250
|
+
const skip = new Set(options.skipHealFingerprints ?? []);
|
|
251
|
+
const candidates = reproducible.filter((f) => !skip.has(f.fingerprint));
|
|
252
|
+
const healTargets = candidates.filter((f) => f.mappedLocation?.isOwnCode);
|
|
239
253
|
if (healTargets.length) {
|
|
240
254
|
say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
|
|
241
255
|
emitPhase("heal");
|
|
@@ -254,6 +268,7 @@ export async function run(options) {
|
|
|
254
268
|
testTimeoutMs: options.testTimeoutMs,
|
|
255
269
|
skipTest: options.skipTest,
|
|
256
270
|
startCommand: options.startCommand,
|
|
271
|
+
budget: options.budget,
|
|
257
272
|
});
|
|
258
273
|
f.heal = result;
|
|
259
274
|
bus.emit("heal", {
|
|
@@ -273,8 +288,14 @@ export async function run(options) {
|
|
|
273
288
|
? pc.green(" ✓ healed")
|
|
274
289
|
: result.status === "unfixed"
|
|
275
290
|
? pc.yellow(" ◐ unfixed")
|
|
276
|
-
:
|
|
291
|
+
: result.status === "budget-exhausted"
|
|
292
|
+
? pc.dim(" ⏹ budget exhausted")
|
|
293
|
+
: pc.red(` ✗ ${result.status}`);
|
|
277
294
|
say(`${mark} ${pc.bold(f.rawMessage.split("\n")[0].slice(0, 60))}`);
|
|
295
|
+
if (result.status === "healed" && result.hunks.length > 0) {
|
|
296
|
+
say(pc.dim(` ${result.filePath}`));
|
|
297
|
+
say(formatDiff(result.hunks));
|
|
298
|
+
}
|
|
278
299
|
if (result.patchPath)
|
|
279
300
|
say(pc.dim(` patch: ${path.relative(repoRoot, result.patchPath)}`));
|
|
280
301
|
if (result.error)
|
|
@@ -295,13 +316,14 @@ export async function run(options) {
|
|
|
295
316
|
}
|
|
296
317
|
}
|
|
297
318
|
}
|
|
298
|
-
else if (
|
|
319
|
+
else if (candidates.length > 0) {
|
|
299
320
|
// Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
|
|
300
|
-
// user rather than silently doing nothing.
|
|
301
|
-
|
|
321
|
+
// user rather than silently doing nothing. Already-handled fingerprints are
|
|
322
|
+
// excluded above, so this only fires for genuinely new findings.
|
|
323
|
+
say(pc.yellow(`Found ${candidates.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
|
|
302
324
|
}
|
|
303
325
|
}
|
|
304
|
-
const reportPath = writeReport(repoRoot, url, findings);
|
|
326
|
+
const reportPath = writeReport(repoRoot, url, findings, options.lang);
|
|
305
327
|
say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
|
|
306
328
|
const counts = {};
|
|
307
329
|
for (const f of findings)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aztrx patrol` — the autonomous bug-patrol loop. It wraps the existing
|
|
3
|
+
* `run()` detect → repro → heal pipeline in a supervisor loop with memory, so
|
|
4
|
+
* instead of "run it and read the output" the tool re-scans on an interval,
|
|
5
|
+
* fixes anything new, and opens a PR per bug — no human in the middle.
|
|
6
|
+
*
|
|
7
|
+
* Guardrails that keep an autonomous run from going off the rails:
|
|
8
|
+
* - memory (PatrolState) — a handled fingerprint is never re-fixed/re-PR'd;
|
|
9
|
+
* - per-session fix cap (`maxFixes`) — bounds LLM spend + PR spam;
|
|
10
|
+
* - scoped staging — PRs carry only the files the patch touched;
|
|
11
|
+
* - a liveness check — a dead target skips the cycle instead of burning a crawl.
|
|
12
|
+
*/
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
import { run } from "../orchestrator.js";
|
|
15
|
+
import { applyVerifiedPatches } from "../heal/apply.js";
|
|
16
|
+
import { PatrolState } from "./state.js";
|
|
17
|
+
import { openPatrolPr, openPatrolBatchPr } from "./pr.js";
|
|
18
|
+
import { recordFindingGif } from "./record.js";
|
|
19
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
/** Any HTTP response means the server is up; only a network failure is "down". */
|
|
21
|
+
async function isAlive(url) {
|
|
22
|
+
try {
|
|
23
|
+
await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function head(f) {
|
|
31
|
+
return f.rawMessage.split("\n")[0].slice(0, 50);
|
|
32
|
+
}
|
|
33
|
+
/** Best-effort GIF recording — a failed capture must never block a PR. */
|
|
34
|
+
async function recordGif(repoRoot, url, f) {
|
|
35
|
+
try {
|
|
36
|
+
return await recordFindingGif(repoRoot, url, f);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
console.log(pc.dim(` · recorded repro skipped: ${e.message}`));
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function patrol(opts) {
|
|
44
|
+
const retryAfterMs = opts.retryAfterMs ?? 30 * 60 * 1000;
|
|
45
|
+
const state = new PatrolState(opts.repoRoot, opts.url, retryAfterMs);
|
|
46
|
+
const maxFixes = opts.maxFixes ?? 5;
|
|
47
|
+
let sessionFixes = 0;
|
|
48
|
+
let sessionPrs = 0;
|
|
49
|
+
const seenFp = new Set();
|
|
50
|
+
// One budget object is shared across every cycle so the cap spans the session,
|
|
51
|
+
// not just a single run.
|
|
52
|
+
const budget = opts.maxSpend && opts.maxSpend > 0 ? { remaining: opts.maxSpend } : undefined;
|
|
53
|
+
console.log(pc.cyan("Aztrx AI — patrol"));
|
|
54
|
+
console.log(pc.dim(`Target: ${opts.url} interval: ${Math.round(opts.intervalMs / 1000)}s max fixes/session: ${maxFixes}` +
|
|
55
|
+
` retry unfixed after: ${Math.round(retryAfterMs / 1000)}s` +
|
|
56
|
+
(budget ? ` spend cap: ${budget.remaining} generations` : "")));
|
|
57
|
+
console.log("");
|
|
58
|
+
for (let cycle = 1;; cycle++) {
|
|
59
|
+
if (!(await isAlive(opts.url))) {
|
|
60
|
+
console.log(pc.dim(`[cycle ${cycle}] target not responding — skipping this pass.`));
|
|
61
|
+
if (opts.once)
|
|
62
|
+
break;
|
|
63
|
+
await sleep(opts.intervalMs);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
console.log(pc.dim(`[cycle ${cycle}] scanning…`));
|
|
67
|
+
let findings;
|
|
68
|
+
try {
|
|
69
|
+
// `ui: true` silences the per-run console so patrol prints its own concise
|
|
70
|
+
// summary instead of the full pipeline log on every cycle.
|
|
71
|
+
findings = await run({
|
|
72
|
+
url: opts.url,
|
|
73
|
+
repoRoot: opts.repoRoot,
|
|
74
|
+
maxActions: opts.maxActions,
|
|
75
|
+
fuzz: opts.fuzz,
|
|
76
|
+
workers: opts.workers,
|
|
77
|
+
allowHosts: opts.allowHosts,
|
|
78
|
+
storageState: opts.storageState,
|
|
79
|
+
login: opts.login,
|
|
80
|
+
loginEmail: opts.loginEmail,
|
|
81
|
+
loginPassword: opts.loginPassword,
|
|
82
|
+
loginUrl: opts.loginUrl,
|
|
83
|
+
lang: opts.lang,
|
|
84
|
+
seed: opts.seed,
|
|
85
|
+
allowDestructive: opts.allowDestructive,
|
|
86
|
+
httpFuzz: opts.httpFuzz,
|
|
87
|
+
httpFuzzMutations: opts.httpFuzzMutations,
|
|
88
|
+
repro: true,
|
|
89
|
+
heal: true,
|
|
90
|
+
healModel: opts.healModel,
|
|
91
|
+
healFastModel: opts.healFastModel,
|
|
92
|
+
testCommand: opts.testCommand,
|
|
93
|
+
testTimeoutMs: opts.testTimeoutMs,
|
|
94
|
+
skipTest: opts.skipTest,
|
|
95
|
+
startCommand: opts.startCommand,
|
|
96
|
+
// Already-handled fingerprints from a prior cycle get skipped before heal,
|
|
97
|
+
// so a re-scan re-detects (to confirm they're still gone) without re-paying
|
|
98
|
+
// the LLM to re-fix them.
|
|
99
|
+
skipHealFingerprints: state.handled(),
|
|
100
|
+
budget,
|
|
101
|
+
ui: true,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
console.log(pc.red(`[cycle ${cycle}] run failed: ${e.message}`));
|
|
106
|
+
if (opts.once)
|
|
107
|
+
break;
|
|
108
|
+
await sleep(opts.intervalMs);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// "found" = distinct crash/error fingerprints ever seen this session.
|
|
112
|
+
for (const f of findings) {
|
|
113
|
+
if (f.severity === "crash" || f.severity === "error")
|
|
114
|
+
seenFp.add(f.fingerprint);
|
|
115
|
+
}
|
|
116
|
+
// Reproducible but not healed → mark unfixable so we don't re-burn the LLM on
|
|
117
|
+
// it every cycle; `PatrolState` backs off and retries it once the cooldown
|
|
118
|
+
// lapses. `budget-exhausted` / `no-llm` are NOT unfixable — they mean we never
|
|
119
|
+
// got a real attempt, so they must not be written into memory as "won't fix".
|
|
120
|
+
for (const f of findings) {
|
|
121
|
+
if ((f.severity === "crash" || f.severity === "error") &&
|
|
122
|
+
f.repro &&
|
|
123
|
+
f.heal &&
|
|
124
|
+
f.heal.status !== "healed" &&
|
|
125
|
+
f.heal.status !== "budget-exhausted" &&
|
|
126
|
+
f.heal.status !== "no-llm" &&
|
|
127
|
+
!state.isHandled(f.fingerprint)) {
|
|
128
|
+
state.markUnfixed(f.fingerprint);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const newHealed = findings.filter((f) => f.heal?.status === "healed" && !state.isHandled(f.fingerprint));
|
|
132
|
+
const toFix = newHealed.slice(0, Math.max(0, maxFixes - sessionFixes));
|
|
133
|
+
if (toFix.length === 0 && sessionFixes >= maxFixes && newHealed.length > 0) {
|
|
134
|
+
console.log(pc.yellow(`[cycle ${cycle}] reached max fixes (${maxFixes}) — no more this session.`));
|
|
135
|
+
}
|
|
136
|
+
if (opts.batch) {
|
|
137
|
+
// Batch: apply each patch, then open one PR carrying every fix that landed.
|
|
138
|
+
const landed = [];
|
|
139
|
+
const files = [];
|
|
140
|
+
for (const f of toFix) {
|
|
141
|
+
const applied = applyVerifiedPatches(opts.repoRoot, [f]);
|
|
142
|
+
if (applied.applied.length === 0) {
|
|
143
|
+
state.markUnfixed(f.fingerprint);
|
|
144
|
+
console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
landed.push(f);
|
|
148
|
+
for (const a of applied.applied)
|
|
149
|
+
files.push(a.filePath);
|
|
150
|
+
}
|
|
151
|
+
if (landed.length > 0) {
|
|
152
|
+
const mediaPaths = [];
|
|
153
|
+
for (const f of landed)
|
|
154
|
+
mediaPaths.push(await recordGif(opts.repoRoot, opts.url, f));
|
|
155
|
+
const pr = await openPatrolBatchPr(opts.repoRoot, landed, opts.url, [...new Set(files)], mediaPaths);
|
|
156
|
+
if (pr.ok && pr.url) {
|
|
157
|
+
sessionPrs++;
|
|
158
|
+
sessionFixes += landed.length;
|
|
159
|
+
for (const f of landed)
|
|
160
|
+
state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
|
|
161
|
+
console.log(pc.green(` ✓ batch PR opened ${pr.url} — ${landed.length} fix(es)`));
|
|
162
|
+
}
|
|
163
|
+
else if (pr.skipped) {
|
|
164
|
+
for (const f of landed)
|
|
165
|
+
state.markPr(f.fingerprint, "existing", pr.branch ?? "");
|
|
166
|
+
console.log(pc.dim(` — batch PR already exists (${pr.branch})`));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
for (const f of landed)
|
|
170
|
+
state.markUnfixed(f.fingerprint);
|
|
171
|
+
console.log(pc.red(` ✗ batch PR failed: ${pr.error}`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
for (const f of toFix) {
|
|
177
|
+
const applied = applyVerifiedPatches(opts.repoRoot, [f]);
|
|
178
|
+
if (applied.applied.length === 0) {
|
|
179
|
+
state.markUnfixed(f.fingerprint);
|
|
180
|
+
console.log(pc.yellow(` ◐ ${head(f)} — apply conflict, marked unfixable.`));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const files = applied.applied.map((a) => a.filePath);
|
|
184
|
+
const mediaPath = await recordGif(opts.repoRoot, opts.url, f);
|
|
185
|
+
const pr = await openPatrolPr(opts.repoRoot, f, opts.url, files, mediaPath);
|
|
186
|
+
if (pr.ok && pr.url) {
|
|
187
|
+
sessionPrs++;
|
|
188
|
+
sessionFixes++;
|
|
189
|
+
state.markPr(f.fingerprint, pr.url, pr.branch ?? "");
|
|
190
|
+
console.log(pc.green(` ✓ PR opened ${pr.url} — ${head(f)}`));
|
|
191
|
+
}
|
|
192
|
+
else if (pr.skipped) {
|
|
193
|
+
state.markPr(f.fingerprint, "existing", pr.branch ?? "");
|
|
194
|
+
console.log(pc.dim(` — already has a PR (${pr.branch})`));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
state.markUnfixed(f.fingerprint);
|
|
198
|
+
console.log(pc.red(` ✗ PR failed: ${pr.error}`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Rolling status — the "found / fixed / PRs" tally that makes the loop read
|
|
203
|
+
// as alive rather than a stream of isolated lines.
|
|
204
|
+
console.log(pc.dim(`[cycle ${cycle}] found ${seenFp.size} · fixed ${sessionFixes} · PRs ${sessionPrs}` +
|
|
205
|
+
(newHealed.length === 0 ? " · nothing new to fix" : "")));
|
|
206
|
+
// Once the session's paid budget is spent there's nothing left to fix — stop
|
|
207
|
+
// rather than silently re-detecting without healing.
|
|
208
|
+
if (budget && budget.remaining <= 0) {
|
|
209
|
+
console.log(pc.yellow(`\nSpend budget exhausted — ${sessionPrs} PR(s) opened this session.`));
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
state.save();
|
|
213
|
+
if (opts.once)
|
|
214
|
+
break;
|
|
215
|
+
await sleep(opts.intervalMs);
|
|
216
|
+
}
|
|
217
|
+
console.log(pc.cyan(`patrol session done — found ${seenFp.size} bug(s) · fixed ${sessionFixes} · PRs ${sessionPrs}`));
|
|
218
|
+
}
|