@q-agent/agent 0.1.0 → 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.
@@ -48,14 +48,16 @@ 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");
53
+ const paths_1 = require("./paths");
52
54
  const playwrightConfig_1 = require("./playwrightConfig");
53
55
  const report_1 = require("./report");
54
56
  const session_1 = require("./session");
55
57
  // Mirrors api/app/config.py's Settings.exec_timeout_s / auth_capture_timeout_s.
56
58
  const EXEC_TIMEOUT_MS = 600_000;
57
59
  const AUTH_CAPTURE_TIMEOUT_MS = 300_000;
58
- const IDLE_POLL_MS = 3_000;
60
+ const IDLE_POLL_MS = 1_000;
59
61
  let activeChild = null;
60
62
  /** Kill whatever child process (capture browser or Playwright run) is active. Used by the CLI's SIGINT handler. */
61
63
  function killActiveChild() {
@@ -68,35 +70,15 @@ function killActiveChild() {
68
70
  }
69
71
  }
70
72
  }
71
- function firstExisting(candidates) {
72
- for (const c of candidates) {
73
- if (fs.existsSync(c))
74
- return c;
75
- }
76
- return null;
77
- }
78
- /** Resolve the vendored capture_auth.cjs, whether running from `dist/` or via ts-node from `src/`. */
79
- function vendorCaptureScript() {
80
- const found = firstExisting([
81
- path.join(__dirname, "..", "..", "vendor", "capture_auth.cjs"),
82
- path.join(__dirname, "..", "vendor", "capture_auth.cjs"),
83
- ]);
84
- if (!found)
85
- throw new Error("capture_auth.cjs not found — the agent package is missing vendor/");
86
- return found;
87
- }
88
- /** The agent package's own node_modules, where @playwright/test + playwright are installed. */
89
- function agentNodeModules() {
90
- return (firstExisting([path.join(__dirname, "..", "..", "node_modules"), path.join(__dirname, "..", "node_modules")]) ??
91
- path.join(__dirname, "..", "..", "node_modules"));
92
- }
93
73
  function nodePathEnv(nm) {
94
74
  const existing = process.env.NODE_PATH;
95
- 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 };
96
76
  }
97
77
  function runProcess(cmd, args, cwd, env, timeoutMs) {
98
78
  return new Promise((resolve) => {
99
- const child = (0, child_process_1.spawn)(cmd, args, { cwd, env, shell: process.platform === "win32" });
79
+ // No shell: cmd is always an absolute node path (nodeBin()) and args are
80
+ // absolute script/CLI paths, so this is safe even when paths contain spaces.
81
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd, env });
100
82
  activeChild = child;
101
83
  let stdout = "";
102
84
  let stderr = "";
@@ -134,9 +116,9 @@ function runProcess(cmd, args, cwd, env, timeoutMs) {
134
116
  */
135
117
  async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
136
118
  fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
137
- const script = vendorCaptureScript();
138
- const nm = agentNodeModules();
139
- const result = await runProcess("node", [script, baseUrl, storageStatePath, sessionStoragePath], nm, nodePathEnv(nm), AUTH_CAPTURE_TIMEOUT_MS);
119
+ const script = (0, paths_1.vendorCaptureScript)();
120
+ const nm = (0, paths_1.agentNodeModules)();
121
+ const result = await runProcess((0, paths_1.nodeBin)(), [script, baseUrl, storageStatePath, sessionStoragePath], nm, nodePathEnv(nm), AUTH_CAPTURE_TIMEOUT_MS);
140
122
  if (result.code !== 0) {
141
123
  console.error(`Auth capture exited ${result.code}: ${(result.stderr || result.stdout || "").slice(0, 800)}`);
142
124
  }
@@ -147,21 +129,15 @@ async function captureAuth(baseUrl, storageStatePath, sessionStoragePath) {
147
129
  return false;
148
130
  }
149
131
  }
150
- /** Prefer the agent's own installed `playwright` binary over a fresh `npx` fetch. */
151
- function playwrightCommand() {
152
- const nm = agentNodeModules();
153
- const bin = path.join(nm, ".bin", process.platform === "win32" ? "playwright.cmd" : "playwright");
154
- if (fs.existsSync(bin))
155
- return { cmd: bin, baseArgs: ["test"] };
156
- return { cmd: "npx", baseArgs: ["playwright", "test"] };
157
- }
158
132
  async function runPlaywright(workDir, workers, specFile) {
159
- const { cmd, baseArgs } = playwrightCommand();
160
- const args = [...baseArgs, `--workers=${workers}`];
133
+ // Invoke Playwright's CLI through the resolved node runtime (`node cli.js test …`)
134
+ // so the same path works from source, via npx, and in a packaged bundle where
135
+ // `node`/`node_modules` live beside the executable rather than on PATH.
136
+ const args = [(0, paths_1.playwrightCli)(), "test", `--workers=${workers}`];
161
137
  if (specFile)
162
138
  args.push(specFile);
163
- const nm = agentNodeModules();
164
- return runProcess(cmd, args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS);
139
+ const nm = (0, paths_1.agentNodeModules)();
140
+ return runProcess((0, paths_1.nodeBin)(), args, workDir, nodePathEnv(nm), EXEC_TIMEOUT_MS);
165
141
  }
166
142
  /**
167
143
  * Best identity `{ticket, caseCode}` for wire events: prefers explicit
@@ -191,64 +167,84 @@ async function failAllResults(cfg, job, message) {
191
167
  const total = job.specs.length;
192
168
  await api.postComplete(cfg, job.executionId, { passed: 0, failed: total, log: message }).catch((err) => console.error("postComplete failed:", err));
193
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
+ }
194
220
  /** Process one claimed job end-to-end: write specs/config, resolve auth, run Playwright, push results. */
195
221
  async function processJob(cfg, job) {
222
+ if (job.heal) {
223
+ await processHealJob(cfg, job, job.heal);
224
+ return;
225
+ }
196
226
  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "qagent-"));
197
227
  try {
198
228
  for (const spec of job.specs) {
199
229
  fs.writeFileSync(path.join(workDir, spec.filename), spec.code, "utf-8");
200
230
  }
201
- // ---- Resolve auth: reuse a valid local session, or capture one headed.
202
- let storageState = "";
203
- let sessionStoragePath = "";
204
- if (job.manualAuth) {
205
- let origin = job.authOrigins[0] || "";
206
- if (!origin && job.baseUrl) {
207
- try {
208
- origin = new URL(job.baseUrl).origin;
209
- }
210
- catch {
211
- origin = "";
212
- }
213
- }
214
- if (origin && (0, session_1.hasValidSession)(origin)) {
215
- const paths = (0, session_1.sessionPathsForOrigin)(origin);
216
- storageState = paths.storageStatePath;
217
- sessionStoragePath = paths.sessionStoragePath;
218
- }
219
- else if (origin && job.baseUrl) {
220
- const paths = (0, session_1.sessionPathsForOrigin)(origin);
221
- await api.postEvent(cfg, job.executionId, "exec.auth.waiting", { url: job.baseUrl });
222
- const captured = await captureAuth(job.baseUrl, paths.storageStatePath, paths.sessionStoragePath);
223
- if (captured) {
224
- storageState = paths.storageStatePath;
225
- sessionStoragePath = paths.sessionStoragePath;
226
- await api.postEvent(cfg, job.executionId, "exec.auth.captured", {});
227
- }
228
- else {
229
- const message = "Manual login was not completed — enable/redo login capture";
230
- await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
231
- await failAllResults(cfg, job, message);
232
- return;
233
- }
234
- }
235
- else {
236
- const message = "Set a base URL for the project first.";
237
- await api.postEvent(cfg, job.executionId, "exec.auth.error", { message });
238
- await failAllResults(cfg, job, message);
239
- return;
240
- }
231
+ const auth = await resolveJobAuth(cfg, job);
232
+ if (auth.error) {
233
+ await failAllResults(cfg, job, auth.error);
234
+ return;
241
235
  }
236
+ const { storageState, sessionStoragePath } = auth;
242
237
  (0, playwrightConfig_1.writeConfig)(workDir, job.workers, job.headless, job.baseUrl, storageState);
243
- // Replay captured sessionStorage (MSAL/SPA tokens) only when a manual-auth
244
- // session actually exists; otherwise normalize spec imports back to
245
- // '@playwright/test' (mirrors `_apply_auth_fixtures`'s `use_fixtures` gate).
246
- const useFixtures = Boolean(job.manualAuth && storageState && sessionStoragePath && fs.statSync(sessionStoragePath).size > 0);
247
- (0, playwrightConfig_1.applyAuthFixtures)(workDir, job.specs.map((s) => s.filename), sessionStoragePath || path.join(workDir, "sessionStorage.json"), useFixtures);
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);
248
243
  const total = job.specs.length;
249
244
  for (let i = 0; i < job.specs.length; i++) {
250
245
  const { ticket, caseCode } = identityFor(job.specs[i]);
251
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 });
252
248
  }
253
249
  // A single-spec job targets just that one file (the "run this test"
254
250
  // action); a multi-case job executes the whole suite — same distinction
@@ -280,6 +276,11 @@ async function processJob(cfg, job) {
280
276
  let passed = 0;
281
277
  let failed = 0;
282
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 = [];
283
284
  for (const spec of job.specs) {
284
285
  const entry = parsed.find((e) => path.basename(e.file) === spec.filename);
285
286
  if (!entry)
@@ -296,21 +297,6 @@ async function processJob(cfg, job) {
296
297
  passed++;
297
298
  else if (entry.status === "fail")
298
299
  failed++;
299
- for (const att of entry.attachments) {
300
- const filePath = path.isAbsolute(att.path) ? att.path : path.join(workDir, att.path);
301
- if (!fs.existsSync(filePath))
302
- continue;
303
- const { ticket, caseCode } = identityFor(spec);
304
- await api
305
- .postEvidence(cfg, job.executionId, {
306
- ticketExternalId: ticket,
307
- caseCode,
308
- kind: att.kind,
309
- filePath,
310
- filename: path.basename(filePath),
311
- })
312
- .catch((err) => console.error("postEvidence failed:", err));
313
- }
314
300
  const { ticket, caseCode } = identityFor(spec);
315
301
  await api.postEvent(cfg, job.executionId, "exec.case.result", {
316
302
  ticket,
@@ -318,6 +304,7 @@ async function processJob(cfg, job) {
318
304
  status: entry.status,
319
305
  durationMs,
320
306
  });
307
+ (0, bus_1.emit)("case-result", { ticket, caseCode, status: entry.status, durationMs });
321
308
  const progress = total ? Math.trunc((100 * matched.size) / total) : 100;
322
309
  await api.postEvent(cfg, job.executionId, "exec.progress", {
323
310
  progress,
@@ -325,6 +312,13 @@ async function processJob(cfg, job) {
325
312
  failed,
326
313
  remaining: total - matched.size,
327
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
+ }
328
322
  }
329
323
  // Any spec Playwright didn't report on (e.g. run_error) is marked failed.
330
324
  for (const spec of job.specs) {
@@ -350,6 +344,240 @@ async function processJob(cfg, job) {
350
344
  // matching the server's own log persistence.
351
345
  const logText = (procOutput || runError || "").slice(-20000);
352
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 });
353
581
  }
354
582
  finally {
355
583
  fs.rmSync(workDir, { recursive: true, force: true });
@@ -374,16 +602,30 @@ async function runAgentLoop(cfg, signal) {
374
602
  console.error("Claim failed:", err.message);
375
603
  }
376
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
+ }
377
617
  await new Promise((r) => setTimeout(r, IDLE_POLL_MS));
378
618
  continue;
379
619
  }
380
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 });
381
622
  try {
382
623
  await processJob(cfg, job);
383
624
  console.log(`Execution #${job.executionId} complete`);
384
625
  }
385
626
  catch (err) {
386
627
  console.error(`Execution #${job.executionId} crashed:`, err);
628
+ (0, bus_1.emit)("error", { message: `Execution #${job.executionId} crashed: ${err.message}` });
387
629
  await api
388
630
  .postComplete(cfg, job.executionId, {
389
631
  passed: 0,