@q-agent/agent 0.1.1 → 0.1.6
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 +28 -1
- package/dist/src/api.js +54 -0
- package/dist/src/cli.js +11 -2
- package/dist/src/config.js +25 -0
- package/dist/src/ensureBrowser.js +4 -1
- package/dist/src/paths.js +15 -0
- package/dist/src/playwrightConfig.js +87 -42
- package/dist/src/report.js +4 -0
- package/dist/src/runner.js +329 -62
- package/dist/src/ui.js +400 -161
- package/dist/test/api.test.js +19 -0
- package/dist/test/playwrightConfig.test.js +84 -0
- package/dist/test/report.test.js +4 -0
- package/package.json +36 -2
package/dist/src/runner.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const os = __importStar(require("os"));
|
|
49
49
|
const path = __importStar(require("path"));
|
|
50
50
|
const api = __importStar(require("./api"));
|
|
51
|
+
const bus_1 = require("./bus");
|
|
51
52
|
const ensureBrowser_1 = require("./ensureBrowser");
|
|
52
53
|
const paths_1 = require("./paths");
|
|
53
54
|
const playwrightConfig_1 = require("./playwrightConfig");
|
|
@@ -56,7 +57,7 @@ const session_1 = require("./session");
|
|
|
56
57
|
// Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
|
|
57
58
|
const EXEC_TIMEOUT_MS = 600_000;
|
|
58
59
|
const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
|
|
59
|
-
const IDLE_POLL_MS =
|
|
60
|
+
const IDLE_POLL_MS = 1_000;
|
|
60
61
|
let activeChild = null;
|
|
61
62
|
/** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
|
|
62
63
|
function killActiveChild() {
|
|
@@ -71,7 +72,7 @@ function killActiveChild() {
|
|
|
71
72
|
}
|
|
72
73
|
function nodePathEnv(nm) {
|
|
73
74
|
const existing = process.env.NODE_PATH;
|
|
74
|
-
return { ...process.env, NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
75
|
+
return { ...process.env, ...(0, paths_1.childNodeEnv)(), NODE_PATH: existing ? `${nm}${path.delimiter}${existing}` : nm };
|
|
75
76
|
}
|
|
76
77
|
function runProcess(cmd, args, cwd, env, timeoutMs) {
|
|
77
78
|
return new Promise((resolve) => {
|
|
@@ -166,64 +167,84 @@ async function failAllResults(cfg, job, message) {
|
|
|
166
167
|
const total = job.specs.length;
|
|
167
168
|
await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
|
|
168
169
|
}
|
|
170
|
+
/** Reuse a valid local session, or capture one headed — shared by the run + heal paths. */
|
|
171
|
+
async function resolveJobAuth(cfg, job) {
|
|
172
|
+
if (!job.manualAuth)
|
|
173
|
+
return { storageState: "", sessionStoragePath: "" };
|
|
174
|
+
let origin = job.authOrigins[0] || "";
|
|
175
|
+
if (!origin && job.baseUrl) {
|
|
176
|
+
try {
|
|
177
|
+
origin = new URL(job.baseUrl).origin;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
origin = "";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
184
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
185
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
186
|
+
}
|
|
187
|
+
if (origin && job.baseUrl) {
|
|
188
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
189
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
190
|
+
(0, bus_1.emit)("auth-waiting", { url: job.baseUrl });
|
|
191
|
+
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
192
|
+
if (captured) {
|
|
193
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
194
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
195
|
+
return { storageState: paths.storageStatePath, sessionStoragePath: paths.sessionStoragePath };
|
|
196
|
+
}
|
|
197
|
+
const message = "Manual login was not completed — enable/redo login capture";
|
|
198
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
199
|
+
(0, bus_1.emit)("error", { message });
|
|
200
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
201
|
+
}
|
|
202
|
+
const message = "Set a base URL for the project first.";
|
|
203
|
+
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
204
|
+
(0, bus_1.emit)("error", { message });
|
|
205
|
+
return { storageState: "", sessionStoragePath: "", error: message };
|
|
206
|
+
}
|
|
207
|
+
/** Best-effort: read the distilled DOM JSON from a parsed attachment list. */
|
|
208
|
+
function loadDistilledDom(workDir, attachments) {
|
|
209
|
+
const att = attachments.find((a) => a.kind === "dom-distilled");
|
|
210
|
+
if (!att)
|
|
211
|
+
return null;
|
|
212
|
+
const p = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
169
220
|
/** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
|
|
170
221
|
async function processJob(cfg, job) {
|
|
222
|
+
if (job.heal) {
|
|
223
|
+
await processHealJob(cfg, job, job.heal);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
171
226
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
|
|
172
227
|
try {
|
|
173
228
|
for (const spec of job.specs) {
|
|
174
229
|
fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
|
|
175
230
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
let origin = job.authOrigins[0] || "";
|
|
181
|
-
if (!origin && job.baseUrl) {
|
|
182
|
-
try {
|
|
183
|
-
origin = new URL(job.baseUrl).origin;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
origin = "";
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (origin && (0, session_1.hasValidSession)(origin)) {
|
|
190
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
191
|
-
storageState = paths.storageStatePath;
|
|
192
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
193
|
-
}
|
|
194
|
-
else if (origin && job.baseUrl) {
|
|
195
|
-
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
196
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
|
|
197
|
-
const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
198
|
-
if (captured) {
|
|
199
|
-
storageState = paths.storageStatePath;
|
|
200
|
-
sessionStoragePath = paths.sessionStoragePath;
|
|
201
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
const message = "Manual login was not completed — enable/redo login capture";
|
|
205
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
206
|
-
await failAllResults(cfg, job, message);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
const message = "Set a base URL for the project first.";
|
|
212
|
-
await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
|
|
213
|
-
await failAllResults(cfg, job, message);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
231
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
232
|
+
if (auth.error) {
|
|
233
|
+
await failAllResults(cfg, job, auth.error);
|
|
234
|
+
return;
|
|
216
235
|
}
|
|
236
|
+
const { storageState, sessionStoragePath } = auth;
|
|
217
237
|
(0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
(0, playwrightConfig_1.
|
|
238
|
+
// Always inject the generated fixtures.ts (DOM capture on every run) + rewrite
|
|
239
|
+
// spec imports to it. sessionStorage replay (MSAL/SPA tokens) is additionally
|
|
240
|
+
// enabled only when a manual-auth session actually exists.
|
|
241
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
242
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
223
243
|
const total = job.specs.length;
|
|
224
244
|
for (let i = 0; i < job.specs.length; i++) {
|
|
225
245
|
const { ticket, caseCode } = identityFor(job.specs[i]);
|
|
226
246
|
await api.postEvent(cfg, job.executionId, "exec.case.running", { ticket, caseCode, index: i + 1, total });
|
|
247
|
+
(0, bus_1.emit)("case-running", { ticket, caseCode, index: i + 1, total });
|
|
227
248
|
}
|
|
228
249
|
// A single-spec job targets just that one file (the "run this test"
|
|
229
250
|
// action); a multi-case job executes the whole suite — same distinction
|
|
@@ -255,6 +276,11 @@ async function processJob(cfg, job) {
|
|
|
255
276
|
let passed = 0;
|
|
256
277
|
let failed = 0;
|
|
257
278
|
const matched = new Set();
|
|
279
|
+
// Evidence uploads are deferred until AFTER results + complete are posted:
|
|
280
|
+
// the browser has already closed when a test finishes, so the web should mark
|
|
281
|
+
// the case/run done immediately rather than waiting on (potentially multi-MB)
|
|
282
|
+
// video/trace/DOM uploads over the network.
|
|
283
|
+
const pendingEvidence = [];
|
|
258
284
|
for (const spec of job.specs) {
|
|
259
285
|
const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
260
286
|
if (!entry)
|
|
@@ -271,21 +297,6 @@ async function processJob(cfg, job) {
|
|
|
271
297
|
passed++;
|
|
272
298
|
else if (entry.status === "fail")
|
|
273
299
|
failed++;
|
|
274
|
-
for (const att of entry.attachments) {
|
|
275
|
-
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
276
|
-
if (!fs.existsSync(filePath))
|
|
277
|
-
continue;
|
|
278
|
-
const { ticket, caseCode } = identityFor(spec);
|
|
279
|
-
await api
|
|
280
|
-
.postEvidence(cfg, job.executionId, {
|
|
281
|
-
ticketExternalId: ticket,
|
|
282
|
-
caseCode,
|
|
283
|
-
kind: att.kind,
|
|
284
|
-
filePath,
|
|
285
|
-
filename: path.basename(filePath),
|
|
286
|
-
})
|
|
287
|
-
.catch((err) => console.error("postEvidence failed:", err));
|
|
288
|
-
}
|
|
289
300
|
const { ticket, caseCode } = identityFor(spec);
|
|
290
301
|
await api.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
291
302
|
ticket,
|
|
@@ -293,6 +304,7 @@ async function processJob(cfg, job) {
|
|
|
293
304
|
status: entry.status,
|
|
294
305
|
durationMs,
|
|
295
306
|
});
|
|
307
|
+
(0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
|
|
296
308
|
const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
|
|
297
309
|
await api.postEvent(cfg, job.executionId, "exec.progress", {
|
|
298
310
|
progress,
|
|
@@ -300,6 +312,13 @@ async function processJob(cfg, job) {
|
|
|
300
312
|
failed,
|
|
301
313
|
remaining: total - matched.size,
|
|
302
314
|
});
|
|
315
|
+
(0, bus_1.emit)("progress", { progress, passed, failed, remaining: total - matched.size });
|
|
316
|
+
for (const att of entry.attachments) {
|
|
317
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
318
|
+
if (!fs.existsSync(filePath))
|
|
319
|
+
continue;
|
|
320
|
+
pendingEvidence.push({ ticket, caseCode, kind: att.kind, filePath });
|
|
321
|
+
}
|
|
303
322
|
}
|
|
304
323
|
// Any spec Playwright didn't report on (e.g. run_error) is marked failed.
|
|
305
324
|
for (const spec of job.specs) {
|
|
@@ -325,6 +344,240 @@ async function processJob(cfg, job) {
|
|
|
325
344
|
// matching the server's own log persistence.
|
|
326
345
|
const logText = (procOutput || runError || "").slice(-20000);
|
|
327
346
|
await api.postComplete(cfg, job.executionId, { passed, failed, log: logText });
|
|
347
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed, failed });
|
|
348
|
+
// Now that the run is marked done, upload the (deferred) evidence artifacts.
|
|
349
|
+
// The web already reflects the outcome; these trail in the background and the
|
|
350
|
+
// Evidence step picks them up as they land.
|
|
351
|
+
for (const ev of pendingEvidence) {
|
|
352
|
+
await api
|
|
353
|
+
.postEvidence(cfg, job.executionId, {
|
|
354
|
+
ticketExternalId: ev.ticket,
|
|
355
|
+
caseCode: ev.caseCode,
|
|
356
|
+
kind: ev.kind,
|
|
357
|
+
filePath: ev.filePath,
|
|
358
|
+
filename: path.basename(ev.filePath),
|
|
359
|
+
})
|
|
360
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Process a standalone manual-login capture: open a headed browser at the
|
|
369
|
+
* project's base URL ON THIS MACHINE, let the operator log in, and save the
|
|
370
|
+
* session locally (keyed by origin) so subsequent runs reuse it. The session
|
|
371
|
+
* never leaves this machine — only the pass/fail outcome is reported back.
|
|
372
|
+
*/
|
|
373
|
+
async function processCapture(cfg, capture) {
|
|
374
|
+
let origin = capture.origin;
|
|
375
|
+
if (!origin && capture.baseUrl) {
|
|
376
|
+
try {
|
|
377
|
+
origin = new URL(capture.baseUrl).origin;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
origin = "";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!origin || !capture.baseUrl) {
|
|
384
|
+
await api.postCaptureComplete(cfg, capture.captureId, false, "Missing base URL / origin").catch(() => { });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const paths = (0, session_1.sessionPathsForOrigin)(origin);
|
|
388
|
+
// Force a fresh login: remove any prior session so captureAuth's
|
|
389
|
+
// "storageState is non-empty" success check can't pass off a stale file
|
|
390
|
+
// (which would falsely report success without opening a browser).
|
|
391
|
+
try {
|
|
392
|
+
fs.rmSync(paths.storageStatePath, { force: true });
|
|
393
|
+
fs.rmSync(paths.sessionStoragePath, { force: true });
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// best-effort
|
|
397
|
+
}
|
|
398
|
+
(0, bus_1.emit)("auth-waiting", { url: capture.baseUrl });
|
|
399
|
+
console.log(`Capturing login for ${capture.projectKey} at ${capture.baseUrl}`);
|
|
400
|
+
let ok = false;
|
|
401
|
+
try {
|
|
402
|
+
ok = await captureAuth(capture.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
console.error("Capture crashed:", err.message);
|
|
406
|
+
}
|
|
407
|
+
if (ok)
|
|
408
|
+
(0, bus_1.emit)("auth-captured", {});
|
|
409
|
+
else
|
|
410
|
+
(0, bus_1.emit)("error", { message: "Login was not captured — the window closed before a session was saved." });
|
|
411
|
+
await api
|
|
412
|
+
.postCaptureComplete(cfg, capture.captureId, ok, ok ? undefined : "Login was not captured")
|
|
413
|
+
.catch((err) => console.error("postCaptureComplete failed:", err));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Run the self-heal LOOP for one case locally (#260): run the spec + capture DOM,
|
|
417
|
+
* and while it fails ask the server to classify + fix it (Claude + KB live
|
|
418
|
+
* server-side), apply the returned code, and re-run — up to `maxAttempts`. Streams
|
|
419
|
+
* `heal.progress`, uploads the final attempt's result + evidence, then posts the
|
|
420
|
+
* outcome to `/agent/heal/{caseId}/finalize`.
|
|
421
|
+
*/
|
|
422
|
+
async function processHealJob(cfg, job, heal) {
|
|
423
|
+
const spec = job.specs[0];
|
|
424
|
+
const { ticket, caseCode } = identityFor(spec);
|
|
425
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-heal-"));
|
|
426
|
+
const progress = (phase, attempt, message, error = "") => api
|
|
427
|
+
.postEvent(cfg, job.executionId, "heal.progress", {
|
|
428
|
+
caseId: heal.caseId, ticket, caseCode, attempt, maxAttempts: heal.maxAttempts,
|
|
429
|
+
phase, message, error: (error || "").slice(0, 600),
|
|
430
|
+
})
|
|
431
|
+
.catch(() => { });
|
|
432
|
+
let currentCode = spec.code;
|
|
433
|
+
let finalStatus = "fail";
|
|
434
|
+
let finalError = "";
|
|
435
|
+
let elapsedMs = 0;
|
|
436
|
+
let lastDom = null;
|
|
437
|
+
let lastAttachments = [];
|
|
438
|
+
let lastFixBefore = null;
|
|
439
|
+
let lastFixAfter = null;
|
|
440
|
+
let blockReason = "";
|
|
441
|
+
let gateReport = "";
|
|
442
|
+
const attempts = [];
|
|
443
|
+
try {
|
|
444
|
+
const auth = await resolveJobAuth(cfg, job);
|
|
445
|
+
if (auth.error) {
|
|
446
|
+
finalError = auth.error;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const { storageState, sessionStoragePath } = auth;
|
|
450
|
+
for (let attempt = 1; attempt <= heal.maxAttempts; attempt++) {
|
|
451
|
+
fs.writeFileSync(path.join(workDir, spec.filename), currentCode, "utf-8");
|
|
452
|
+
(0, playwrightConfig_1.writeConfig)(workDir, 1, job.headless, job.baseUrl, storageState);
|
|
453
|
+
const replaySession = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
|
|
454
|
+
(0, playwrightConfig_1.applyFixtures)(workDir, [spec.filename], sessionStoragePath || path.join(workDir, "sessionStorage.json"), replaySession);
|
|
455
|
+
await progress("running", attempt, `Running spec (attempt ${attempt}/${heal.maxAttempts})`);
|
|
456
|
+
const started = Date.now();
|
|
457
|
+
const { stdout, stderr, timedOut } = await runPlaywright(workDir, 1, spec.filename);
|
|
458
|
+
elapsedMs = Date.now() - started;
|
|
459
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
460
|
+
let entry;
|
|
461
|
+
const reportPath = path.join(workDir, "report.json");
|
|
462
|
+
if (!timedOut && fs.existsSync(reportPath)) {
|
|
463
|
+
try {
|
|
464
|
+
const parsed = (0, report_1.parsePlaywrightReport)(JSON.parse(fs.readFileSync(reportPath, "utf-8")));
|
|
465
|
+
entry = parsed.find((e) => path.basename(e.file) === spec.filename);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// fall through to the no-report error below
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const rec = { attempt };
|
|
472
|
+
if (entry) {
|
|
473
|
+
lastAttachments = entry.attachments;
|
|
474
|
+
lastDom = loadDistilledDom(workDir, entry.attachments);
|
|
475
|
+
}
|
|
476
|
+
if (entry && entry.status === "pass") {
|
|
477
|
+
finalStatus = "pass";
|
|
478
|
+
finalError = "";
|
|
479
|
+
rec.status = "pass";
|
|
480
|
+
attempts.push(rec);
|
|
481
|
+
await progress("passed", attempt, "Spec passed");
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
finalStatus = "fail";
|
|
485
|
+
finalError = entry
|
|
486
|
+
? entry.error_message || output || "Test failed"
|
|
487
|
+
: timedOut
|
|
488
|
+
? "Playwright run timed out"
|
|
489
|
+
: output || "No result reported by Playwright";
|
|
490
|
+
rec.status = "fail";
|
|
491
|
+
rec.error = finalError;
|
|
492
|
+
if (attempt >= heal.maxAttempts) {
|
|
493
|
+
attempts.push(rec);
|
|
494
|
+
await progress("failed", attempt, "Still failing after max attempts", finalError);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
await progress("fixing", attempt, "Asking the server to fix the spec", finalError);
|
|
498
|
+
let fix;
|
|
499
|
+
try {
|
|
500
|
+
fix = await api.postHealFix(cfg, heal.caseId, {
|
|
501
|
+
currentCode, error: finalError, output, domDistilled: lastDom, attempt,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
finalError = `Heal fix request failed: ${err.message}`;
|
|
506
|
+
rec.error = finalError;
|
|
507
|
+
attempts.push(rec);
|
|
508
|
+
await progress("failed", attempt, finalError, finalError);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
rec.action = fix.action;
|
|
512
|
+
if (fix.action === "product_defect") {
|
|
513
|
+
finalStatus = "product_defect";
|
|
514
|
+
rec.failureClass = fix.failureClass;
|
|
515
|
+
rec.reason = fix.reason;
|
|
516
|
+
attempts.push(rec);
|
|
517
|
+
await progress("product_defect", attempt, "Product defect suspected — routing to report", finalError);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (fix.action === "blocked") {
|
|
521
|
+
finalStatus = "blocked";
|
|
522
|
+
blockReason = fix.reason || "";
|
|
523
|
+
gateReport = fix.gate || "";
|
|
524
|
+
rec.gate = "blocked";
|
|
525
|
+
rec.error = fix.reason;
|
|
526
|
+
attempts.push(rec);
|
|
527
|
+
await progress("failed", attempt, "Fix blocked by placeholder gate", fix.reason || "");
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (fix.action === "rejected") {
|
|
531
|
+
finalStatus = "fail";
|
|
532
|
+
rec.gate = "rejected";
|
|
533
|
+
rec.error = fix.reason;
|
|
534
|
+
attempts.push(rec);
|
|
535
|
+
await progress("failed", attempt, "Fix rejected", fix.reason || "");
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
// action === "fixed": apply it and re-run.
|
|
539
|
+
rec.fixed = true;
|
|
540
|
+
rec.diff = fix.diff;
|
|
541
|
+
attempts.push(rec);
|
|
542
|
+
lastFixBefore = currentCode;
|
|
543
|
+
lastFixAfter = fix.code || currentCode;
|
|
544
|
+
currentCode = fix.code || currentCode;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Report the final attempt's result + evidence against the heal execution.
|
|
548
|
+
const passResult = finalStatus === "pass";
|
|
549
|
+
await api
|
|
550
|
+
.postResult(cfg, job.executionId, {
|
|
551
|
+
file: spec.filename, status: passResult ? "pass" : "fail",
|
|
552
|
+
duration_ms: elapsedMs, error_message: passResult ? "" : finalError,
|
|
553
|
+
})
|
|
554
|
+
.catch((err) => console.error("postResult failed:", err));
|
|
555
|
+
for (const att of lastAttachments) {
|
|
556
|
+
const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
|
|
557
|
+
if (!fs.existsSync(filePath))
|
|
558
|
+
continue;
|
|
559
|
+
await api
|
|
560
|
+
.postEvidence(cfg, job.executionId, {
|
|
561
|
+
ticketExternalId: ticket, caseCode, kind: att.kind, filePath, filename: path.basename(filePath),
|
|
562
|
+
})
|
|
563
|
+
.catch((err) => console.error("postEvidence failed:", err));
|
|
564
|
+
}
|
|
565
|
+
await api
|
|
566
|
+
.postEvent(cfg, job.executionId, "exec.case.result", {
|
|
567
|
+
ticket, caseCode, status: passResult ? "pass" : "fail", durationMs: elapsedMs,
|
|
568
|
+
})
|
|
569
|
+
.catch(() => { });
|
|
570
|
+
await api
|
|
571
|
+
.postHealFinalize(cfg, heal.caseId, {
|
|
572
|
+
finalStatus, finalError, finalCode: currentCode,
|
|
573
|
+
blockReason, gateReport, domDistilled: lastDom,
|
|
574
|
+
lastFixBefore, lastFixAfter, attempts,
|
|
575
|
+
})
|
|
576
|
+
.catch((err) => console.error("postHealFinalize failed:", err));
|
|
577
|
+
await api
|
|
578
|
+
.postComplete(cfg, job.executionId, { passed: passResult ? 1 : 0, failed: passResult ? 0 : 1, log: finalError })
|
|
579
|
+
.catch((err) => console.error("postComplete failed:", err));
|
|
580
|
+
(0, bus_1.emit)("job-complete", { executionId: job.executionId, passed: passResult ? 1 : 0, failed: passResult ? 0 : 1 });
|
|
328
581
|
}
|
|
329
582
|
finally {
|
|
330
583
|
fs.rmSync(workDir, { recursive: true, force: true });
|
|
@@ -349,16 +602,30 @@ async function runAgentLoop(cfg, signal) {
|
|
|
349
602
|
console.error("Claim failed:", err.message);
|
|
350
603
|
}
|
|
351
604
|
if (!job) {
|
|
605
|
+
// No execution queued — check for a standalone login-capture request.
|
|
606
|
+
let capture = null;
|
|
607
|
+
try {
|
|
608
|
+
capture = await api.claimNextCapture(cfg);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
console.error("Capture claim failed:", err.message);
|
|
612
|
+
}
|
|
613
|
+
if (capture) {
|
|
614
|
+
await processCapture(cfg, capture);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
352
617
|
await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
|
|
353
618
|
continue;
|
|
354
619
|
}
|
|
355
620
|
console.log(`Claimed execution #${job.executionId} (run ${job.runCode}, ${job.specs.length} spec(s))`);
|
|
621
|
+
(0, bus_1.emit)("job-claimed", { executionId: job.executionId, runCode: job.runCode, total: job.specs.length });
|
|
356
622
|
try {
|
|
357
623
|
await processJob(cfg, job);
|
|
358
624
|
console.log(`Execution #${job.executionId} complete`);
|
|
359
625
|
}
|
|
360
626
|
catch (err) {
|
|
361
627
|
console.error(`Execution #${job.executionId} crashed:`, err);
|
|
628
|
+
(0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
|
|
362
629
|
await api
|
|
363
630
|
.postComplete(cfg, job.executionId, {
|
|
364
631
|
passed: 0,
|