halo-agent 2.4.6 → 2.5.0
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/orchestrator.js +82 -27
- package/package.json +1 -1
- package/scanAccessibility.js +43 -0
- package/smartFill.js +42 -9
package/orchestrator.js
CHANGED
|
@@ -272,6 +272,15 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
272
272
|
const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
273
273
|
fillResult.filled += retryResult.filled;
|
|
274
274
|
fillResult.skipped = retryResult.skipped;
|
|
275
|
+
// Preserve sticky upload flags. If pass 1 uploaded resume / cover letter,
|
|
276
|
+
// pass 2's smartFill won't redo it (the file slot is already filled and
|
|
277
|
+
// smartFill skips), so retryResult.resumeUploaded reads false — but the
|
|
278
|
+
// file IS in the slot. ORing keeps the truth across passes; without this
|
|
279
|
+
// the orchestrator's blanket fallback uploader (below) re-uploads resume
|
|
280
|
+
// into whatever file input it finds next, which on Greenhouse is the
|
|
281
|
+
// cover-letter slot. That's the Robinhood corruption bug.
|
|
282
|
+
fillResult.resumeUploaded = fillResult.resumeUploaded || retryResult.resumeUploaded;
|
|
283
|
+
fillResult.coverLetterUploaded = fillResult.coverLetterUploaded || retryResult.coverLetterUploaded;
|
|
275
284
|
console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
|
|
276
285
|
}
|
|
277
286
|
|
|
@@ -292,9 +301,33 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
292
301
|
// the cover-letter slot too. smartFill now reports resumeUploaded, so we
|
|
293
302
|
// only fall back when the planner genuinely missed the resume field.
|
|
294
303
|
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
295
|
-
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
|
|
296
|
-
|
|
297
|
-
|
|
304
|
+
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader (resume-scoped).');
|
|
305
|
+
// Scoped selectors. Only match file inputs that are CLEARLY resume slots:
|
|
306
|
+
// - aria-label / name / id mentions "resume" or "cv"
|
|
307
|
+
// - dedicated [data-testid*="resume"]
|
|
308
|
+
// - inputs inside a section/fieldset/div whose nearest heading text
|
|
309
|
+
// contains "Resume" or "CV" (Greenhouse pattern — uses a sibling
|
|
310
|
+
// <h3>Resume/CV*</h3>). We use Playwright's :has() to chain.
|
|
311
|
+
//
|
|
312
|
+
// Critically we EXCLUDE selectors that would match the cover-letter slot.
|
|
313
|
+
// If no resume-scoped match exists, we don't upload anywhere — better to
|
|
314
|
+
// surface a real failure than silently corrupt the cover-letter slot.
|
|
315
|
+
const resumeOnlySelectors = [
|
|
316
|
+
'input[type="file"][aria-label*="resume" i]',
|
|
317
|
+
'input[type="file"][aria-label*="cv" i]',
|
|
318
|
+
'input[type="file"][name*="resume" i]',
|
|
319
|
+
'input[type="file"][id*="resume" i]',
|
|
320
|
+
'[data-testid*="resume" i]',
|
|
321
|
+
].join(', ');
|
|
322
|
+
const uploaded = await uploadFile(page, resumeOnlySelectors, tempResumeFile);
|
|
323
|
+
if (uploaded) {
|
|
324
|
+
fillResult.filled = (fillResult.filled || 0) + 1;
|
|
325
|
+
fillResult.resumeUploaded = true;
|
|
326
|
+
} else {
|
|
327
|
+
console.warn('[orchestrator] Fallback uploader found no resume-scoped slot. Refusing to upload to generic file inputs to avoid cover-letter corruption.');
|
|
328
|
+
}
|
|
329
|
+
} else if (tempResumeFile && fillResult.resumeUploaded) {
|
|
330
|
+
console.log('[orchestrator] Skipping fallback uploader — smartFill already uploaded resume.');
|
|
298
331
|
}
|
|
299
332
|
|
|
300
333
|
await reportStatus('IN_PROGRESS', {
|
|
@@ -727,31 +760,53 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
727
760
|
console.warn(`[orchestrator] verify-submit unavailable: ${e.message}`);
|
|
728
761
|
}
|
|
729
762
|
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
763
|
+
// Firecrawl is ADVISORY, not authoritative. The DOM-error pass above is
|
|
764
|
+
// the ground truth: if `errorsAfter.hasErrors === false` (no validation
|
|
765
|
+
// banner, no aria-invalid, no "is required" text), the form was accepted.
|
|
766
|
+
//
|
|
767
|
+
// Firecrawl false-positives we've seen in the wild:
|
|
768
|
+
// - Ashby keeps the reCAPTCHA badge DOM-mounted on the thank-you page;
|
|
769
|
+
// Firecrawl's extract reads it as "Recaptcha requires verification."
|
|
770
|
+
// - Greenhouse thank-you pages include apologetic copy ("we'll be in
|
|
771
|
+
// touch if it didn't go through") that the LLM reads as failure.
|
|
772
|
+
// - URL doesn't always change on success (embed=js Ashby just swaps
|
|
773
|
+
// the panel to a "Thanks for applying" state).
|
|
774
|
+
//
|
|
775
|
+
// So we no longer throw NEEDS_ATTENTION on `verdict.submitted === false`
|
|
776
|
+
// when local signals say success. Local truth comes from:
|
|
777
|
+
// - errorsAfter.hasErrors === false (no DOM validation errors)
|
|
778
|
+
// - findSubmitButton(page) returns null (submit button replaced by
|
|
779
|
+
// confirmation UI), OR
|
|
780
|
+
// - URL matched /thank|confirm|success|applied|submitted/ during the
|
|
781
|
+
// wait above, OR
|
|
782
|
+
// - No required fields still empty.
|
|
783
|
+
//
|
|
784
|
+
// Only when local signals ALSO look suspicious do we route to REVIEWING
|
|
785
|
+
// (NOT NEEDS_ATTENTION-throw — REVIEWING keeps tab open + parks).
|
|
737
786
|
if (verdict.submitted === false) {
|
|
738
|
-
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
787
|
+
const reason = verdict.error_message || 'verifier said not submitted';
|
|
788
|
+
// Local re-check: is the form gone / page in a confirmation state?
|
|
789
|
+
const submitStillThere = await findSubmitButton(page).then((b) => !!b).catch(() => false);
|
|
790
|
+
const urlLooksConfirmed = /thank|confirm|success|applied|submitted/i.test(page.url());
|
|
791
|
+
const postSkipped2 = (ctx.skippedFields || []).map((s) => s.label);
|
|
792
|
+
const reqCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped2, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
793
|
+
|
|
794
|
+
// If submit button gone OR URL changed OR no validation errors AND
|
|
795
|
+
// no empty required fields → trust the DOM, treat as DONE.
|
|
796
|
+
const domSaysClean = !errorsAfter.hasErrors && !reqCheck.hasEmptyRequired;
|
|
797
|
+
const looksSubmitted = !submitStillThere || urlLooksConfirmed;
|
|
798
|
+
if (domSaysClean && (looksSubmitted || true /* DOM clean is enough */)) {
|
|
799
|
+
console.log(`[orchestrator] Verifier said "${reason}" but DOM is clean (no errors, no empties, submit ${submitStillThere ? 'still present' : 'gone'}, url ${urlLooksConfirmed ? 'confirmed' : 'unchanged'}). Treating as submitted.`);
|
|
800
|
+
// Fall through into the success-decision block below by overriding the verdict.
|
|
801
|
+
verdict.submitted = true;
|
|
802
|
+
verdict.source = verdict.source || 'dom-clean-override';
|
|
803
|
+
} else {
|
|
804
|
+
// DOM also looks off → real ambiguity. REVIEWING, not NEEDS_ATTENTION.
|
|
805
|
+
// Keep tab open so the user can finish manually. No throw.
|
|
806
|
+
console.warn(`[orchestrator] Verifier said "${reason}" and DOM is ambiguous (errors=${errorsAfter.hasErrors}, empties=${reqCheck.hasEmptyRequired}). Parking REVIEWING.`);
|
|
807
|
+
verdict.submitted = null;
|
|
808
|
+
verdict.source = 'dom-ambiguous';
|
|
809
|
+
}
|
|
755
810
|
}
|
|
756
811
|
|
|
757
812
|
// Decide final state (DONE / REVIEWING) based on verifier verdict +
|
package/package.json
CHANGED
package/scanAccessibility.js
CHANGED
|
@@ -311,6 +311,48 @@ async function enrichFromDom(page, axFields) {
|
|
|
311
311
|
}
|
|
312
312
|
} catch {}
|
|
313
313
|
|
|
314
|
+
// Group-heading disambiguation for file inputs with generic labels.
|
|
315
|
+
// Greenhouse renders two `<input type="file">` inputs both labeled
|
|
316
|
+
// "Attach" — one under a `<h3>Resume/CV*</h3>` heading, one under
|
|
317
|
+
// `<h3>Cover Letter</h3>`. The AX name only sees "Attach" for both,
|
|
318
|
+
// so the planner can't route resume vs cover_letter deterministically.
|
|
319
|
+
// Walk up to the nearest heading and capture its text as groupLabel.
|
|
320
|
+
let groupLabel = '';
|
|
321
|
+
try {
|
|
322
|
+
const isFile = (el.tagName === 'INPUT' && String(el.type || '').toLowerCase() === 'file');
|
|
323
|
+
if (isFile) {
|
|
324
|
+
// Use the resolved AX/placeholder label if present; else fall back
|
|
325
|
+
// to the input's textContent / aria-label.
|
|
326
|
+
const lblForCheck = (el.getAttribute('aria-label') || el.placeholder || '').trim();
|
|
327
|
+
// Conservative: only fire when the immediate label is a generic
|
|
328
|
+
// upload verb. We don't want to clobber a real label like "Resume".
|
|
329
|
+
const generic = /^(attach|attach\s*file|upload|upload\s*file|choose file|select file|browse|add file|file)$/i;
|
|
330
|
+
// The visible button label is often on a sibling <button>; check that too.
|
|
331
|
+
let buttonText = '';
|
|
332
|
+
try {
|
|
333
|
+
const wrapper = el.closest('div, section, fieldset, [class*="field"], [class*="upload"]');
|
|
334
|
+
const btn = wrapper && wrapper.querySelector('button, [role="button"]');
|
|
335
|
+
if (btn) buttonText = safeText(btn.innerText || btn.textContent || '').trim();
|
|
336
|
+
} catch {}
|
|
337
|
+
const labelLooksGeneric = !lblForCheck || generic.test(lblForCheck) || generic.test(buttonText || '');
|
|
338
|
+
if (labelLooksGeneric) {
|
|
339
|
+
// Walk up to 8 ancestors and look for a heading inside that ancestor.
|
|
340
|
+
let p = el.parentElement, hops = 0;
|
|
341
|
+
while (p && hops < 8) {
|
|
342
|
+
const heading = p.querySelector('h1, h2, h3, h4, h5, h6, legend, [role="heading"]');
|
|
343
|
+
if (heading && !heading.contains(el)) {
|
|
344
|
+
const t = safeText(heading.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
345
|
+
if (t && t.length > 0 && t.length < 150) {
|
|
346
|
+
groupLabel = t;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
p = p.parentElement; hops++;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {}
|
|
355
|
+
|
|
314
356
|
return {
|
|
315
357
|
mmid,
|
|
316
358
|
tag,
|
|
@@ -321,6 +363,7 @@ async function enrichFromDom(page, axFields) {
|
|
|
321
363
|
ariaLabel: el.getAttribute('aria-label') || '',
|
|
322
364
|
placeholder: el.placeholder || '',
|
|
323
365
|
recoveredLabel,
|
|
366
|
+
groupLabel,
|
|
324
367
|
selectorHint,
|
|
325
368
|
currentValue,
|
|
326
369
|
options,
|
package/smartFill.js
CHANGED
|
@@ -125,6 +125,10 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
125
125
|
const result = await openAndPickOption(root, locator, item.value, {
|
|
126
126
|
config: ctx.config, jobId: ctx.jobId, label: field.label, field,
|
|
127
127
|
});
|
|
128
|
+
// Always dismiss any lingering portal/option-list before the next
|
|
129
|
+
// field so stale overlays don't intercept the next click.
|
|
130
|
+
const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
|
|
131
|
+
await pageRef.keyboard.press('Escape').catch(() => {});
|
|
128
132
|
if (result.ok) return result;
|
|
129
133
|
// Fall through to text-fill if no option matched at all
|
|
130
134
|
// (some Greenhouse comboboxes accept free-text in addition to options).
|
|
@@ -150,9 +154,14 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
case 'click_option': {
|
|
153
|
-
|
|
157
|
+
const result = await openAndPickOption(root, locator, item.value, {
|
|
154
158
|
config: ctx.config, jobId: ctx.jobId, label: field.label, field,
|
|
155
159
|
});
|
|
160
|
+
// Always dismiss any lingering portal/option-list before the next
|
|
161
|
+
// field so stale overlays don't intercept the next click.
|
|
162
|
+
const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
|
|
163
|
+
await pageRef.keyboard.press('Escape').catch(() => {});
|
|
164
|
+
return result;
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
case 'set_checkbox': {
|
|
@@ -656,7 +665,22 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
|
656
665
|
// - Ashby's emotion-hashed _input_ class inside a _fieldEntry_ group
|
|
657
666
|
try {
|
|
658
667
|
const fieldIsTypeahead = !!(llmCtx && llmCtx.field && llmCtx.field.isTypeahead);
|
|
659
|
-
|
|
668
|
+
// React-select disqualifier. Greenhouse, Lever, and many other ATSs ship
|
|
669
|
+
// react-select-styled dropdowns that carry `aria-autocomplete="list"` but
|
|
670
|
+
// are NOT async typeaheads — their options are pre-loaded and rendered as
|
|
671
|
+
// `.select__option` under a portal. If we type into them, react-select
|
|
672
|
+
// clears the input on blur because the typed text doesn't match any
|
|
673
|
+
// selectable option. The result is a silently-empty required field.
|
|
674
|
+
// Route those straight to tryReactSelect instead.
|
|
675
|
+
const isReactSelect = await triggerLocator.evaluate((el) => {
|
|
676
|
+
const matchSel = '.select__input, [class*="select__input"], .select__control, [class*="select__control"], [class*="-control"][class*="select"]';
|
|
677
|
+
if (el.matches?.(matchSel)) return true;
|
|
678
|
+
if (el.closest?.('.select__control, [class*="select__control"], .select, [class*="-container"][class*="select"]')) return true;
|
|
679
|
+
// Descendant check (when locator climbed to a wrapper)
|
|
680
|
+
if (el.querySelector?.('.select__input, [class*="select__input"], .select__control, [class*="select__control"]')) return true;
|
|
681
|
+
return false;
|
|
682
|
+
}).catch(() => false);
|
|
683
|
+
const domIsTypeahead = isReactSelect ? false : await triggerLocator.evaluate((el) => {
|
|
660
684
|
const probe = (n) => {
|
|
661
685
|
if (!n || n.nodeType !== 1) return false;
|
|
662
686
|
const role = n.getAttribute('role');
|
|
@@ -685,12 +709,15 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
|
|
|
685
709
|
return false;
|
|
686
710
|
}).catch(() => false);
|
|
687
711
|
|
|
688
|
-
if (
|
|
712
|
+
if (isReactSelect) {
|
|
713
|
+
console.log('[smartFill] openAndPickOption: react-select detected → skipping typeahead path, routing to tryReactSelect');
|
|
714
|
+
}
|
|
715
|
+
if ((fieldIsTypeahead || domIsTypeahead) && !isReactSelect) {
|
|
689
716
|
const why = [];
|
|
690
717
|
if (fieldIsTypeahead) why.push('field.isTypeahead');
|
|
691
718
|
if (domIsTypeahead) why.push('dom signals');
|
|
692
719
|
console.log(`[smartFill] openAndPickOption: detected typeahead (${why.join(', ')}) → typeAndPickSuggestion first`);
|
|
693
|
-
const r = await typeAndPickSuggestion(root, triggerLocator, value);
|
|
720
|
+
const r = await typeAndPickSuggestion(root, triggerLocator, value, { strict: true });
|
|
694
721
|
if (r && r.ok) return r;
|
|
695
722
|
// Fall through to specialized + generic paths on no-match.
|
|
696
723
|
console.log(`[smartFill] typeahead path did not land (${r && r.reason}); falling through to generic dispatch.`);
|
|
@@ -907,8 +934,14 @@ async function reactSafeType(root, locator, value) {
|
|
|
907
934
|
|
|
908
935
|
/**
|
|
909
936
|
* Type-and-pick for typeahead inputs (Greenhouse Location etc).
|
|
937
|
+
*
|
|
938
|
+
* opts.strict: when true, "no suggestion appeared" is reported as failure
|
|
939
|
+
* instead of silently accepting the typed value as free text. Pass this
|
|
940
|
+
* for known-listbox combobox fields where accepting free text would be
|
|
941
|
+
* silently cleared by the framework on blur (react-select, etc.).
|
|
910
942
|
*/
|
|
911
|
-
async function typeAndPickSuggestion(root, locator, value) {
|
|
943
|
+
async function typeAndPickSuggestion(root, locator, value, opts) {
|
|
944
|
+
const strict = !!(opts && opts.strict);
|
|
912
945
|
// root: Page or Frame. Option lists for an in-frame field render inside
|
|
913
946
|
// that frame, so they must be located via root, not the top page.
|
|
914
947
|
const page = (root && typeof root.page === 'function') ? root.page() : root;
|
|
@@ -941,15 +974,15 @@ async function typeAndPickSuggestion(root, locator, value) {
|
|
|
941
974
|
await root.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
|
|
942
975
|
} catch {
|
|
943
976
|
const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
|
|
944
|
-
if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
|
|
945
|
-
return { ok: false, reason: 'typeahead opened no suggestions' };
|
|
977
|
+
if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
|
|
978
|
+
return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
|
|
946
979
|
}
|
|
947
980
|
const opts = root.locator(optionSel);
|
|
948
981
|
const count = await opts.count().catch(() => 0);
|
|
949
982
|
if (count === 0) {
|
|
950
983
|
const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
|
|
951
|
-
if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
|
|
952
|
-
return { ok: false, reason: 'typeahead opened no suggestions' };
|
|
984
|
+
if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
|
|
985
|
+
return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
|
|
953
986
|
}
|
|
954
987
|
const texts = await opts.allTextContents().catch(() => []);
|
|
955
988
|
const v = firstChunk.toLowerCase();
|