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 +72 -33
- package/orchestrator.js +23 -6
- package/package.json +1 -1
- package/scanPage.js +15 -2
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
|
-
|
|
22
|
-
await
|
|
23
|
-
|
|
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,
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
if (
|
|
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.
|
|
59
|
-
}, value);
|
|
60
|
-
|
|
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
|
-
|
|
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) {
|
|
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 {
|
|
746
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
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'
|
|
342
|
+
if (sfField === 'resume') return { ...f, category: 'file:resume' };
|
|
343
343
|
if (profileMap[sfField] !== undefined) return { ...f, category: 'profile:' + profileMap[sfField] };
|
|
344
344
|
}
|
|
345
|
-
|
|
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
|
|