halo-agent 1.3.2 → 1.3.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/filler.js CHANGED
@@ -17,22 +17,11 @@ function jitter(base) {
17
17
  * Fires the full event sequence that frameworks listen for.
18
18
  */
19
19
  async function setFieldValue(page, selector, value) {
20
+ // Wrapper that delegates to the React-aware locator variant.
20
21
  try {
21
- await page.locator(selector).first().waitFor({ timeout: 3000 });
22
- await page.evaluate(({ sel, val }) => {
23
- const el = document.querySelector(sel);
24
- if (!el) return;
25
- el.focus();
26
- // React's synthetic event system requires using the native setter
27
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
28
- || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
29
- if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
30
- el.dispatchEvent(new Event('focus', { bubbles: true }));
31
- el.dispatchEvent(new Event('input', { bubbles: true }));
32
- el.dispatchEvent(new Event('change', { bubbles: true }));
33
- el.dispatchEvent(new Event('blur', { bubbles: true }));
34
- }, { sel: selector, val: value });
35
- return true;
22
+ const loc = page.locator(selector).first();
23
+ await loc.waitFor({ timeout: 3000 });
24
+ return await setFieldValueByHandle(page, loc, value);
36
25
  } catch {
37
26
  return false;
38
27
  }
@@ -41,23 +30,53 @@ async function setFieldValue(page, selector, value) {
41
30
  /**
42
31
  * Set a field value by Playwright element handle (used after semantic matching).
43
32
  */
44
- async function setFieldValueByHandle(page, elementHandle, value) {
33
+ async function setFieldValueByHandle(page, elementOrLocator, value) {
34
+ // Strategy ladder, hardest-React-friendly first.
35
+ // The Reddit-Greenhouse run proved everything below the FIRST strategy
36
+ // fired but the field still appeared empty: React's controlled-input
37
+ // validator rejects programmatic value writes that don't go through a
38
+ // real event pipeline. Playwright's locator.fill() simulates a real
39
+ // user typing (pointerdown → focus → keystroke events → blur) and IS
40
+ // the only thing modern React forms (Greenhouse, Ashby, Lever, Workday)
41
+ // reliably accept. We try it first and fall back to native-setter only
42
+ // if Playwright's path fails (rare; mostly when the element isn't a
43
+ // standard input).
44
+
45
+ // 1. Playwright native fill — works for >99% of React forms
46
+ try {
47
+ await elementOrLocator.fill(String(value), { timeout: 5000 });
48
+ // Verify the value actually stuck (React might have rejected it)
49
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
50
+ if (got && got.trim()) return true;
51
+ // If empty after fill, React rejected it — fall through to manual setter
52
+ } catch {
53
+ // Locator may not support .fill() (custom div pretending to be input).
54
+ // Fall through.
55
+ }
56
+
57
+ // 2. Click + keyboard.type — slower but bullets through React harder
45
58
  try {
46
- await page.evaluate(({ val }) => {
47
- const el = arguments[0]; // handled via elementHandle approach below
48
- }, { val: value });
49
- // Use evaluate with the handle directly
50
- await elementHandle.evaluate((el, val) => {
59
+ await elementOrLocator.click({ timeout: 2000 });
60
+ await elementOrLocator.fill('', { timeout: 2000 }).catch(() => {});
61
+ await page.keyboard.type(String(value), { delay: 20 });
62
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
63
+ if (got && got.trim()) return true;
64
+ } catch {}
65
+
66
+ // 3. Native setter dance — last resort for non-standard elements
67
+ try {
68
+ await elementOrLocator.evaluate((el, val) => {
51
69
  el.focus();
52
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
53
- || Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
54
- if (nativeInputValueSetter) nativeInputValueSetter.call(el, val);
55
- el.dispatchEvent(new Event('focus', { bubbles: true }));
70
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
71
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
72
+ if (setter) setter.call(el, val); else el.value = val;
56
73
  el.dispatchEvent(new Event('input', { bubbles: true }));
57
74
  el.dispatchEvent(new Event('change', { bubbles: true }));
58
- el.dispatchEvent(new Event('blur', { bubbles: true }));
59
- }, value);
60
- return true;
75
+ el.blur();
76
+ }, String(value));
77
+ // Verify
78
+ const got = await elementOrLocator.inputValue({ timeout: 1000 }).catch(() => null);
79
+ return !!(got && got.trim());
61
80
  } catch {
62
81
  return false;
63
82
  }
@@ -637,6 +656,8 @@ async function fillFields(page, aep, options = {}) {
637
656
 
638
657
  // ── Pass 3: fill each field ───────────────────────────────────────────────
639
658
  for (const { field, value, source } of resolved) {
659
+ const labelShort = (field.label || field.selector || '?').slice(0, 50);
660
+
640
661
  // Skip if already answered on a previous page
641
662
  const trackKey = field.label || field.selector;
642
663
  if (ctx && trackKey && ctx.answeredFields.has(trackKey)) {
@@ -660,8 +681,15 @@ async function fillFields(page, aep, options = {}) {
660
681
  continue;
661
682
  }
662
683
 
663
- // No value for a profile field — missing from profile, skip
664
- if (!value) { skipped++; continue; }
684
+ // No value for a profile field — missing from profile, skip.
685
+ // Log this loudly: it's almost always the actual cause of "the agent
686
+ // didn't fill anything." profile_fill missing a value the user definitely
687
+ // entered means the AEP builder isn't sending it from strategic_profile.
688
+ if (!value) {
689
+ console.warn(`[filler] no-value ${source}: "${labelShort}" (category=${field.category}) — value missing from AEP`);
690
+ skipped++;
691
+ continue;
692
+ }
665
693
 
666
694
  // Locate the element — use the scanner's selector first, then fall back to semantic finder
667
695
  let locator = null;
@@ -690,7 +718,10 @@ async function fillFields(page, aep, options = {}) {
690
718
  locator = await semanticFindField(page, { field_id: field.id || field.name, label: field.label }).catch(() => null);
691
719
  }
692
720
 
693
- if (!locator) { skipped++; continue; }
721
+ if (!locator) {
722
+ console.warn(`[filler] no-locator: "${labelShort}" (selector="${(field.selector || '').slice(0,60)}") — element not visible / selector dead`);
723
+ skipped++; continue;
724
+ }
694
725
 
695
726
  // Special case: cover letter — type character by character
696
727
  if (field.category === 'cover_letter' && field.tag === 'textarea') {
@@ -739,11 +770,19 @@ async function fillFields(page, aep, options = {}) {
739
770
  try {
740
771
  const ok = await fillLocator(page, locator, value, field.label);
741
772
  if (ok) {
773
+ const valShort = String(value).slice(0, 40).replace(/\n/g, ' ');
774
+ console.log(`[filler] filled (${source}): "${labelShort}" = "${valShort}${String(value).length > 40 ? '...' : ''}"`);
742
775
  filled++;
743
776
  if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source });
744
777
  await delay();
745
- } else { failed++; }
746
- } catch { failed++; }
778
+ } else {
779
+ console.warn(`[filler] fill-failed: "${labelShort}" fillLocator returned false`);
780
+ failed++;
781
+ }
782
+ } catch (e) {
783
+ console.warn(`[filler] fill-threw: "${labelShort}" — ${e.message}`);
784
+ failed++;
785
+ }
747
786
  }
748
787
 
749
788
  // ── Legacy fallback: also run old profile map + semantic matcher ──────────
package/orchestrator.js CHANGED
@@ -402,30 +402,47 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
402
402
  console.warn(`[orchestrator] verify-submit unavailable: ${e.message}`);
403
403
  }
404
404
 
405
- if (!verdict.submitted) {
405
+ // Three-way verdict:
406
+ // submitted: true → real success, mark DONE
407
+ // submitted: false → real failure, NEEDS_ATTENTION with the error
408
+ // submitted: null → could not verify (Firecrawl down/missing).
409
+ // Bounce to REVIEWING so the user eyeballs the
410
+ // screenshot — never silently mark DONE on
411
+ // unverified submits (that was the Chalk bug).
412
+ if (verdict.submitted === false) {
406
413
  const reason = verdict.error_message || 'Submission did not confirm — form may still have errors';
407
414
  console.warn(`[orchestrator] Submission NOT verified. Reason: ${reason}`);
408
415
  await reportStatus('NEEDS_ATTENTION', {
409
416
  review_screenshot_r2_key: confirmKey || null,
410
- needs_attention_reason: `Submit clicked but not confirmed: ${reason}`,
417
+ needs_attention_reason: `Submit clicked but Ashby/ATS rejected it: ${reason}`,
411
418
  intervention_type: 'submit_failed',
412
419
  step: 'VERIFY',
413
420
  step_detail: reason.slice(0, 200),
414
421
  fields_filled: cumulativeFilled,
415
422
  });
416
- // Do NOT clearCheckpoint — user may dismiss + re-queue, and a stale
417
- // checkpoint would resume into the same failed state. The dismiss flow
418
- // clears it (DELETE /apply-queue/:id sets form_checkpoint_json = NULL).
419
423
  throw new Error(`Submission failed verification: ${reason}`);
420
424
  }
421
425
 
426
+ if (verdict.submitted === null) {
427
+ console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
428
+ await reportStatus('REVIEWING', {
429
+ review_screenshot_r2_key: confirmKey || null,
430
+ step: 'REVIEWING',
431
+ step_detail: 'Could not auto-verify — please confirm the submit',
432
+ fields_filled: cumulativeFilled,
433
+ });
434
+ // Stop here; user clicks Submit on dashboard → /apply-queue/submit/:id
435
+ // will flip to DONE. Don't return — let the function return naturally.
436
+ return;
437
+ }
438
+
422
439
  await reportStatus('DONE', {
423
440
  confirmation_screenshot_r2_key: confirmKey || null,
424
441
  fields_filled: cumulativeFilled,
425
442
  });
426
443
  await clearCheckpoint(config, queueId);
427
444
 
428
- console.log(`[orchestrator] Done (verified): ${queueItem.company} - ${queueItem.title}${verdict.source === 'firecrawl' ? ' · firecrawl-verified' : ' · unverified'}`);
445
+ console.log(`[orchestrator] Done (verified): ${queueItem.company} - ${queueItem.title} · firecrawl-verified`);
429
446
 
430
447
  // Post fill session data to backend for learning loop
431
448
  await postFillSession(config, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/scanPage.js CHANGED
@@ -339,10 +339,17 @@ async function scanAshby(page) {
339
339
  if (f.name && f.name.startsWith('_systemfield_')) {
340
340
  const sfField = f.name.replace('_systemfield_', '');
341
341
  const profileMap = { name: 'full_name', email: 'email', phone: 'phone', linkedin: 'linkedin', website: 'portfolio', resume: null };
342
- if (sfField === 'resume' || f.inputType === 'file') return { ...f, category: 'file:resume' };
342
+ if (sfField === 'resume') return { ...f, category: 'file:resume' };
343
343
  if (profileMap[sfField] !== undefined) return { ...f, category: 'profile:' + profileMap[sfField] };
344
344
  }
345
- if (f.inputType === 'file') return { ...f, category: 'file:resume' };
345
+ // File inputs: distinguish resume vs cover-letter vs other by the visible
346
+ // label. Without this, Ashby's separate cover-letter file input was being
347
+ // shadowed by 'file:resume' and uploadResume() only handles one resume.
348
+ if (f.inputType === 'file') {
349
+ const lbl = (f.label || '').toLowerCase();
350
+ if (/cover\s*letter/.test(lbl)) return { ...f, category: 'file:cover_letter' };
351
+ return { ...f, category: 'file:resume' };
352
+ }
346
353
  // Modern Ashby uses UUID names for ALL fields (Name, Email, Phone, custom).
347
354
  // Classify by LABEL first — that's the only signal that distinguishes
348
355
  // a profile field from a custom question when the name attr is opaque.
@@ -525,6 +532,12 @@ async function scanPage(page, ats) {
525
532
  });
526
533
 
527
534
  console.log(`[scanPage] ${platform}: found ${out.length} fields (${out.filter(f => f.category.startsWith('profile')).length} profile, ${out.filter(f => f.category === 'custom').length} custom, ${out.filter(f => f.category === 'eeo').length} eeo)`);
535
+ // Per-field breakdown so we can SEE why a "Name" field ended up classified
536
+ // as 'custom' instead of 'profile:full_name' — without this, every fill
537
+ // failure is a guessing game.
538
+ for (const f of out) {
539
+ console.log(`[scanPage] - ${f.category.padEnd(22)} | ${(f.label || '(no label)').slice(0, 60)} | ${f.tag}/${f.inputType}`);
540
+ }
528
541
  return out;
529
542
  }
530
543