halo-agent 2.2.4 → 2.3.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 CHANGED
@@ -48,13 +48,21 @@ async function fillFields(page, aep, opts) {
48
48
  plannedActions: result.planned || 0,
49
49
  fellBackToLegacy: !!result.fallback,
50
50
  resumeUploaded: !!result.resumeUploaded,
51
+ coverLetterUploaded: !!result.coverLetterUploaded,
51
52
  };
52
53
  }
53
54
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
54
55
  const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
56
+ const { scanWorkday } = require('./scanWorkday');
55
57
 
56
- // ATS types that need vision fallback due to shadow DOM / canvas
57
- const VISION_ATS = new Set(['workday', 'icims', 'taleo', 'sap', 'successfactors']);
58
+ // ATS types that need vision fallback due to shadow DOM / canvas.
59
+ // 'workday' was here historically because the AX-tree scan returned no usable
60
+ // fields on Workday's shadow-DOM forms. Phase 3 of the Workday end-to-end fix
61
+ // added scanWorkday() (DOM walk via [data-automation-id^="formField-"]) +
62
+ // pickWorkdayOption (scroll-and-scrape for long dropdowns), so Workday is now
63
+ // DOM-first like Greenhouse/Lever, with vision reserved as fallback only.
64
+ const VISION_ATS = new Set(['icims', 'taleo', 'sap', 'successfactors']);
65
+ const WORKDAY_ATS = 'workday';
58
66
 
59
67
  /**
60
68
  * Create a fresh FormContext for tracking state across a multi-page application.
@@ -684,12 +692,45 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
684
692
  }
685
693
 
686
694
  if (finalState === 'REVIEWING') {
695
+ // CRITICAL: keep the Chrome tab OPEN and the agent ALIVE so the user can
696
+ // actually take over. Previously this branch reported REVIEWING then
697
+ // fell through → the `finally` closed the tab and the agent moved on,
698
+ // leaving a REVIEWING row with no open tab and nothing listening — a
699
+ // dead-end the user couldn't resolve. Now: leave the tab open, tell the
700
+ // user what's blank, and wait for them to finish in Chrome + click
701
+ // Submit (which the agent's waitForSubmitConfirmation is parked on).
702
+ leaveTabOpen = true;
703
+ // Re-check exactly which required fields are still empty so the message
704
+ // names them (the user is staring at the form; tell them what to fix).
705
+ const stillEmpty = await detectRequiredEmpties(page, {
706
+ excludeLabels: (ctx.skippedFields || []).map((s) => s.label),
707
+ resumeUploaded: !!fillResult.resumeUploaded,
708
+ }).catch(() => ({ emptyFields: [] }));
709
+ const emptyList = stillEmpty.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
710
+ const reason = emptyList
711
+ ? `${stillEmpty.emptyFields.length} field(s) need you: ${emptyList}. Fill them in the open Chrome tab, then click Submit.`
712
+ : `Couldn't auto-confirm this submit. Eyeball the open Chrome tab, then click Submit.`;
687
713
  await reportStatus('REVIEWING', {
688
714
  review_screenshot_r2_key: confirmKey || null,
689
715
  step: 'REVIEWING',
690
- step_detail: `Submit clicked at ${verdictUrl.slice(0, 100)} — verifier unavailable, please confirm`,
716
+ step_detail: reason.slice(0, 200),
717
+ needs_attention_reason: reason,
691
718
  fields_filled: cumulativeFilled,
692
719
  });
720
+ console.log(`[orchestrator] REVIEWING — tab left open. ${reason}`);
721
+ // Park here until the user clicks Submit on the dashboard. The tab stays
722
+ // open the whole time so they can fill the blanks in their own Chrome.
723
+ await waitForSubmitConfirmation(config, queueId);
724
+ // They filled the blanks and clicked Submit — click the form's submit.
725
+ const finishBtn = await findSubmitButton(page).catch(() => null);
726
+ if (finishBtn) {
727
+ console.log('[orchestrator] User confirmed — clicking submit on the finished form.');
728
+ await finishBtn.click().catch(() => {});
729
+ await page.waitForTimeout(2500);
730
+ }
731
+ await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
732
+ await clearCheckpoint(config, queueId);
733
+ console.log(`[orchestrator] Done (user-completed): ${queueItem.company} - ${queueItem.title}`);
693
734
  } else {
694
735
  leaveTabOpen = true;
695
736
  await reportStatus('DONE', {
@@ -724,8 +765,13 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
724
765
  filled_actions: filledActions,
725
766
  // Pass through which actual files were uploaded — backend writes to
726
767
  // submissions.resume_pdf_r2_key / cover_letter_pdf_r2_key for receipt.
727
- resume_pdf_r2_key: aep?.recommended_resume?.pdf_r2_key || null,
728
- cover_letter_pdf_r2_key: aep?.cover_letter_pdf?.pdf_r2_key || null,
768
+ // ONLY report a file key if the agent ACTUALLY uploaded it. Previously
769
+ // the cover-letter key was sent whenever the packet HAD one, even when
770
+ // the upload FAILED ("no cover_letter file available") — so the receipt
771
+ // claimed a cover letter that was never submitted. The receipt must
772
+ // reflect what landed on the form, not what the packet contained.
773
+ resume_pdf_r2_key: (fillResult.resumeUploaded || tempResumeFile) ? (aep?.recommended_resume?.pdf_r2_key || null) : null,
774
+ cover_letter_pdf_r2_key: fillResult.coverLetterUploaded ? (aep?.cover_letter_pdf?.pdf_r2_key || null) : null,
729
775
  }).catch(() => {}); // non-critical
730
776
 
731
777
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.4",
3
+ "version": "2.3.0",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "filler.js",
24
24
  "scanPage.js",
25
25
  "scanAccessibility.js",
26
+ "scanWorkday.js",
26
27
  "smartFill.js",
27
28
  "detectFormErrors.js",
28
29
  "captcha.js",
@@ -609,4 +609,4 @@ async function scanAccessibility(page) {
609
609
  return out;
610
610
  }
611
611
 
612
- module.exports = { scanAccessibility };
612
+ module.exports = { scanAccessibility, injectMmid };
package/scanWorkday.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Workday DOM scanner (Phase 3 of the Workday end-to-end fix).
5
+ *
6
+ * Workday forms live in a React shadow-DOM and the canonical anchor for every
7
+ * fillable widget is `[data-automation-id^="formField-"]`. The public repo
8
+ * shanjilcoding/resume-tailor-public's workday.js proved this approach is
9
+ * reliable enough for the common case — we borrow the SELECTORS and
10
+ * HEURISTICS (not the bookmarklet runtime) into Playwright.
11
+ *
12
+ * Why it exists: previously the agent treated Workday as VISION-ONLY in
13
+ * orchestrator.js (VISION_ATS set), which meant every Workday job cost a
14
+ * Claude Opus call per field (~40 iterations / $0.06 per job, slow). This
15
+ * scanner returns fields in the same shape scanAccessibility produces, so the
16
+ * existing smartFillPage / planner pipeline can drive Workday DOM-first.
17
+ * Vision stays as a fallback for fields this DOM pass misses (custom widgets,
18
+ * vendor-extended Workday tenants).
19
+ *
20
+ * Output shape (matches scanAccessibility for executor compatibility):
21
+ * {
22
+ * mmid, role, label, description, required, options, selectorHint,
23
+ * inputType, value, isTypeahead, groupLabel, filledAlready,
24
+ * }
25
+ */
26
+
27
+ const { injectMmid } = require('./scanAccessibility');
28
+
29
+ async function scanWorkday(page) {
30
+ await injectMmid(page).catch(() => 0);
31
+
32
+ return await page.evaluate(() => {
33
+ function clean(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
34
+
35
+ // Workday inputs live INSIDE the formField wrapper; pick the most
36
+ // representative one (the visible interactive element).
37
+ function pickInner(root) {
38
+ return root.querySelector(
39
+ 'input:not([type=hidden]):not([type=submit]),' +
40
+ 'textarea, select,' +
41
+ '[role="combobox"], button[aria-haspopup="listbox"], button[aria-haspopup="dialog"],' +
42
+ '[role="checkbox"], [role="radio"]'
43
+ );
44
+ }
45
+
46
+ // Map DOM tag/role → the same role string scanAccessibility uses, so the
47
+ // planner doesn't need a special case for Workday.
48
+ function inferRole(root, inner) {
49
+ if (!inner) return 'textbox';
50
+ const tag = (inner.tagName || '').toLowerCase();
51
+ const role = inner.getAttribute('role');
52
+ const type = (inner.getAttribute('type') || '').toLowerCase();
53
+ if (tag === 'textarea') return 'textarea';
54
+ if (tag === 'select') return 'listbox';
55
+ if (role === 'combobox' || (tag === 'button' && /listbox|dialog/.test(inner.getAttribute('aria-haspopup') || ''))) return 'combobox';
56
+ if (role === 'checkbox' || type === 'checkbox') return 'checkbox';
57
+ if (role === 'radio' || type === 'radio') return 'radio';
58
+ if (type === 'file' || root.querySelector('input[type=file]')) return 'file_upload';
59
+ return 'textbox';
60
+ }
61
+
62
+ function nearestLabel(root, inner) {
63
+ // 1. label[for=id]
64
+ if (inner && inner.id) {
65
+ const lbl = document.querySelector(`label[for="${inner.id}"]`);
66
+ if (lbl) return clean(lbl.innerText || lbl.textContent || '').replace(/\*\s*$/, '').trim();
67
+ }
68
+ // 2. aria-labelledby
69
+ const ariaLbl = (inner && inner.getAttribute('aria-labelledby')) || root.getAttribute('aria-labelledby');
70
+ if (ariaLbl) {
71
+ const t = ariaLbl.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean)
72
+ .map((n) => clean(n.innerText || '')).join(' ').trim();
73
+ if (t) return t.replace(/\*\s*$/, '').trim();
74
+ }
75
+ // 3. A label/legend inside the formField wrapper (Workday's typical layout)
76
+ const lbl = root.querySelector('label, legend, [class*="label"]');
77
+ if (lbl) return clean(lbl.innerText || '').replace(/\*\s*$/, '').trim();
78
+ // 4. data-automation-id minus prefix as a last resort
79
+ const daid = root.getAttribute('data-automation-id') || '';
80
+ return daid.replace(/^formField-/, '').replace(/[-_]/g, ' ').trim() || '(unknown)';
81
+ }
82
+
83
+ function isRequired(root, inner) {
84
+ if (inner) {
85
+ if (inner.required) return true;
86
+ if (inner.getAttribute('aria-required') === 'true') return true;
87
+ }
88
+ // Workday marks required labels with a trailing * inside the wrapper.
89
+ const lbl = root.querySelector('label, legend');
90
+ const raw = lbl ? (lbl.innerText || lbl.textContent || '') : '';
91
+ return /\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw);
92
+ }
93
+
94
+ // Get options if they're eagerly rendered. Workday's dropdowns mount their
95
+ // listbox on click, so this often returns null — smartFill's openAndPickOption
96
+ // already handles the click-then-look-then-scrape pattern.
97
+ function eagerOptions(root) {
98
+ const opts = [];
99
+ root.querySelectorAll('option, [role="option"]').forEach((o) => {
100
+ const t = clean(o.innerText || o.textContent || '');
101
+ if (t && opts.indexOf(t) === -1) opts.push(t);
102
+ });
103
+ return opts.length > 0 ? opts.slice(0, 200) : null;
104
+ }
105
+
106
+ function isCurrentlyFilled(inner, root) {
107
+ if (!inner) return false;
108
+ const tag = (inner.tagName || '').toLowerCase();
109
+ const type = (inner.getAttribute('type') || '').toLowerCase();
110
+ if (type === 'checkbox' || type === 'radio') return !!inner.checked;
111
+ if (type === 'file') return !!(inner.files && inner.files.length > 0);
112
+ if (tag === 'select') return !!inner.value && !!inner.value.trim();
113
+ // Combobox / button trigger: look for the value display sibling.
114
+ if (inner.getAttribute('role') === 'combobox' || tag === 'button') {
115
+ // Workday renders the selected value in a span with the displayed text.
116
+ const valEl = root.querySelector('[data-automation-id*="value"], [class*="selectedValue"]');
117
+ if (valEl) return !!clean(valEl.innerText || valEl.textContent || '');
118
+ }
119
+ return !!clean(inner.value || '');
120
+ }
121
+
122
+ const results = [];
123
+ const seen = new Set();
124
+ document.querySelectorAll('[data-automation-id^="formField-"]').forEach((node) => {
125
+ const root = node;
126
+ const daid = root.getAttribute('data-automation-id') || '';
127
+ // De-dup: some Workday templates nest formField wrappers; keep the outermost.
128
+ if (seen.has(daid)) return;
129
+ seen.add(daid);
130
+ const inner = pickInner(root);
131
+ const mmid = (inner && inner.getAttribute('mmid')) || root.getAttribute('mmid') || null;
132
+ if (!mmid) return; // injectMmid skipped it — likely not interactive
133
+ const label = nearestLabel(root, inner);
134
+ const role = inferRole(root, inner);
135
+ results.push({
136
+ mmid: String(mmid),
137
+ role,
138
+ label,
139
+ description: (inner && (inner.getAttribute('aria-description') || inner.getAttribute('placeholder'))) || null,
140
+ required: isRequired(root, inner),
141
+ options: eagerOptions(root),
142
+ selectorHint: `[data-automation-id="${daid}"]`,
143
+ inputType: (inner && inner.getAttribute('type')) || null,
144
+ value: (inner && clean(inner.value || '')) || '',
145
+ isTypeahead: role === 'combobox',
146
+ groupLabel: null,
147
+ filledAlready: isCurrentlyFilled(inner, root),
148
+ });
149
+ });
150
+ return results;
151
+ }).catch(() => []);
152
+ }
153
+
154
+ module.exports = { scanWorkday };
package/smartFill.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const { scanAccessibility } = require('./scanAccessibility');
20
+ const { scanWorkday } = require('./scanWorkday');
20
21
  const { fillFields: legacyFillFields } = require('./filler');
21
22
 
22
23
  // ── Plan executor ───────────────────────────────────────────────────────────
@@ -188,14 +189,61 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
188
189
  }
189
190
  }
190
191
  }
191
- // Strategy 2: just click the labeled option directly (custom radios).
192
+ // Strategy 2: custom radios (Ashby etc) — click the labeled option,
193
+ // but SCOPED to THIS field's own radiogroup. The old code matched
194
+ // `[role="radio"]:has-text("Yes")` across the WHOLE page via .first(),
195
+ // so on a form with several Yes/No questions it clicked the first
196
+ // "Yes" on the page (or nothing) instead of the one for this field —
197
+ // the Gen Digital bug where sponsorship + office-commitment radios
198
+ // ended up blank. Anchor on the scanned element's group container.
192
199
  const v = String(item.value);
193
- const opt = root.locator(`[role="radio"]:has-text("${v}"), label:has-text("${v}")`).first();
194
- if (await opt.isVisible({ timeout: 800 }).catch(() => false)) {
195
- await opt.click({ timeout: 1500 });
196
- return { ok: true, reason: `radio (label): ${v}` };
200
+ // Tag the field's enclosing radiogroup so we can scope option lookup.
201
+ const groupId = await locator.evaluate((el) => {
202
+ // Walk up to the nearest radiogroup / fieldset / Ashby field entry.
203
+ let p = el;
204
+ for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
205
+ const role = p.getAttribute && p.getAttribute('role');
206
+ const cls = String(p.className || '');
207
+ if (role === 'radiogroup' || p.tagName === 'FIELDSET'
208
+ || /_fieldEntry_|ashby-application-form-field-entry|radio.?group/i.test(cls)) {
209
+ const gid = 'halo-rg-' + Math.random().toString(36).slice(2, 8);
210
+ p.setAttribute('data-halo-rg', gid);
211
+ return gid;
212
+ }
213
+ }
214
+ return null;
215
+ }).catch(() => null);
216
+
217
+ const groupRoot = groupId ? root.locator(`[data-halo-rg="${groupId}"]`) : root;
218
+ // Within the group, find the option whose visible label matches value.
219
+ // Try role=radio, label, and a generic clickable row with the text.
220
+ const optInGroup = groupRoot.locator(
221
+ `[role="radio"]:has-text("${v}"), label:has-text("${v}"), ` +
222
+ `[class*="option"]:has-text("${v}"), [class*="radio"]:has-text("${v}")`
223
+ ).first();
224
+ if (await optInGroup.isVisible({ timeout: 1000 }).catch(() => false)) {
225
+ await optInGroup.click({ timeout: 1500 }).catch(async () => {
226
+ await optInGroup.click({ force: true, timeout: 1500 });
227
+ });
228
+ // Verify it registered: the matched radio should now be checked /
229
+ // aria-checked. Ashby toggles aria-checked on the role=radio element.
230
+ await page.waitForTimeout(150);
231
+ const stuck = await optInGroup.evaluate((el) => {
232
+ const r = el.querySelector('[role="radio"]') || el.closest('[role="radio"]') || el;
233
+ return r.getAttribute('aria-checked') === 'true'
234
+ || (r.querySelector && r.querySelector('input:checked') != null)
235
+ || (r.tagName === 'INPUT' && r.checked);
236
+ }).catch(() => true); // if we can't tell, assume click worked
237
+ if (stuck) return { ok: true, reason: `radio (scoped label): ${v}` };
238
+ // Didn't register — click the input inside the option as a fallback.
239
+ const innerInput = optInGroup.locator('input[type="radio"]').first();
240
+ if (await innerInput.count().catch(() => 0)) {
241
+ await innerInput.check({ force: true, timeout: 1500 }).catch(() => {});
242
+ return { ok: true, reason: `radio (scoped input): ${v}` };
243
+ }
244
+ return { ok: true, reason: `radio (clicked, unverified): ${v}` };
197
245
  }
198
- return { ok: false, reason: `radio option "${item.value}" not found` };
246
+ return { ok: false, reason: `radio option "${item.value}" not found in group` };
199
247
  } catch (e) {
200
248
  return { ok: false, reason: `radio failed: ${e.message}` };
201
249
  }
@@ -456,6 +504,133 @@ async function tryReactSelect(root, triggerLocator, value) {
456
504
  }
457
505
  }
458
506
 
507
+ /**
508
+ * Workday combobox / multi-select option picker.
509
+ *
510
+ * Workday's dropdowns mount a popover with [data-automation-id="activeListContainer"]
511
+ * holding [data-automation-id="promptOption"] entries. Long lists (country,
512
+ * university, state) are VIRTUALIZED — only ~10 entries render at once; you
513
+ * have to scroll the container to materialize the rest. The legacy
514
+ * openAndPickOption path would click + look once + give up on a 5-of-200 hit
515
+ * rate, which is why VISION_ATS = ['workday'] existed in the first place.
516
+ *
517
+ * Strategy (borrowed from shanjilcoding/resume-tailor-public/src/lib/workday.js
518
+ * lines ~600-750):
519
+ * 1. Click trigger, wait for activeListContainer.
520
+ * 2. Type the value to filter (Workday narrows the list as you type — saves
521
+ * most of the scrolling).
522
+ * 3. Snapshot visible promptOptions, scroll the container, snapshot again.
523
+ * Repeat until the option set is stable for 2 consecutive rounds OR we
524
+ * exceed a scroll budget.
525
+ * 4. Fuzzy-match the typed value against the union of all option texts.
526
+ * 5. Click the matching promptOption; verify the trigger now shows a value.
527
+ */
528
+ async function pickWorkdayOption(root, triggerLocator, value) {
529
+ const page = (root && typeof root.page === 'function') ? root.page() : root;
530
+ try {
531
+ // Open the dropdown by clicking the trigger. Workday's trigger is often
532
+ // a button or div with role="combobox" — both respond to .click().
533
+ await triggerLocator.click({ timeout: 2500 }).catch(async () => {
534
+ await triggerLocator.click({ force: true, timeout: 1500 });
535
+ });
536
+ await page.waitForTimeout(300);
537
+
538
+ // Locate the popover. It's appended at document.body and identified by
539
+ // [data-automation-id="activeListContainer"] (Workday's canonical anchor).
540
+ const containerSel = '[data-automation-id="activeListContainer"]';
541
+ const optionSel = '[data-automation-id="promptOption"]';
542
+ try {
543
+ await root.locator(containerSel).first().waitFor({ state: 'visible', timeout: 2200 });
544
+ } catch {
545
+ return { ok: false, reason: 'workday: activeListContainer never appeared' };
546
+ }
547
+
548
+ // If the trigger also accepts text (searchable combobox), type the value
549
+ // to filter the list. Speeds up long-list matching dramatically.
550
+ const typed = String(value).split(/[,;]/)[0].trim();
551
+ try {
552
+ const isTextInput = await triggerLocator.evaluate((el) => {
553
+ const tag = (el.tagName || '').toLowerCase();
554
+ const type = (el.getAttribute('type') || '').toLowerCase();
555
+ return tag === 'input' && (type === 'text' || type === 'search' || type === '');
556
+ }).catch(() => false);
557
+ if (isTextInput && typed) {
558
+ await triggerLocator.fill('').catch(() => {});
559
+ await page.keyboard.type(typed, { delay: 30 });
560
+ await page.waitForTimeout(450); // let virtualized list re-render filtered set
561
+ }
562
+ } catch { /* not a text input — skip filter step */ }
563
+
564
+ // Scroll-and-scrape. Snapshot options, scroll, snapshot, repeat until
565
+ // stable for 2 rounds or we hit the scroll budget.
566
+ const container = root.locator(containerSel).first();
567
+ const seen = new Map(); // text → first index seen
568
+ const MAX_SCROLLS = 30;
569
+ let stableRounds = 0;
570
+ let prevSize = 0;
571
+ for (let i = 0; i < MAX_SCROLLS; i++) {
572
+ const visible = await root.locator(optionSel).evaluateAll((nodes) => {
573
+ return nodes.map((n) => (n.innerText || n.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
574
+ }).catch(() => []);
575
+ for (const t of visible) { if (!seen.has(t)) seen.set(t, seen.size); }
576
+ if (seen.size === prevSize) {
577
+ stableRounds += 1;
578
+ if (stableRounds >= 2) break; // list exhausted
579
+ } else {
580
+ stableRounds = 0;
581
+ prevSize = seen.size;
582
+ }
583
+ // Scroll the container to materialize more entries.
584
+ await container.evaluate((el) => { el.scrollTop = el.scrollTop + el.clientHeight; }).catch(() => {});
585
+ await page.waitForTimeout(180);
586
+ }
587
+
588
+ const options = Array.from(seen.keys());
589
+ if (options.length === 0) {
590
+ return { ok: false, reason: 'workday: scrolled but no promptOption text found' };
591
+ }
592
+
593
+ // Fuzzy match: exact → starts-with → contains → token overlap.
594
+ const v = String(value).toLowerCase().trim();
595
+ let pickText = options.find((t) => t.toLowerCase() === v)
596
+ || options.find((t) => t.toLowerCase().startsWith(v))
597
+ || options.find((t) => t.toLowerCase().includes(v) || v.includes(t.toLowerCase()));
598
+ if (!pickText) {
599
+ const vTokens = v.split(/\s+/).filter((t) => t.length > 2);
600
+ pickText = options.find((t) => {
601
+ const tt = t.toLowerCase();
602
+ return vTokens.some((vt) => tt.includes(vt));
603
+ });
604
+ }
605
+ if (!pickText) {
606
+ await page.keyboard.press('Escape').catch(() => {});
607
+ return { ok: false, reason: `workday: no option matched "${value}" (had ${options.length}: ${options.slice(0, 4).join(' / ')}${options.length > 4 ? '…' : ''})` };
608
+ }
609
+
610
+ // Click by visible text — scoped to the popover so we don't accidentally
611
+ // hit a same-named link elsewhere on the page.
612
+ const target = root.locator(`${containerSel} ${optionSel}`).filter({ hasText: pickText }).first();
613
+ await target.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => {});
614
+ await target.click({ timeout: 1500 }).catch(async () => {
615
+ await target.click({ force: true, timeout: 1500 });
616
+ });
617
+ await page.waitForTimeout(200);
618
+
619
+ // Verify: the popover should have closed and the trigger should reflect
620
+ // the selection. If neither holds, the click silently failed.
621
+ const stillOpen = await root.locator(containerSel).first().isVisible({ timeout: 400 }).catch(() => false);
622
+ if (stillOpen) {
623
+ // One more try with a keyboard Enter (Workday sometimes needs that on the
624
+ // highlighted option).
625
+ await page.keyboard.press('Enter').catch(() => {});
626
+ await page.waitForTimeout(150);
627
+ }
628
+ return { ok: true, reason: `workday picked: ${pickText}` };
629
+ } catch (e) {
630
+ return { ok: false, reason: `workday option picker threw: ${e.message}` };
631
+ }
632
+ }
633
+
459
634
  async function openAndPickOption(root, triggerLocator, value, llmCtx) {
460
635
  // root: Page or Frame. .locator works on both; keyboard/waitForTimeout
461
636
  // need the Page. For in-frame fields, option menus render inside the
@@ -477,6 +652,49 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
477
652
  if (itlResult !== null && itlResult.ok) return itlResult;
478
653
  } catch {}
479
654
 
655
+ try {
656
+ // Ashby async typeahead. Ashby's Location field is role="combobox" with
657
+ // aria-haspopup="listbox" and an input class like `_input_v5ami_28`. It
658
+ // renders NO options until you type, then fetches matches over the
659
+ // network (~700ms) and shows them as [role="option"]. The generic
660
+ // open-and-look path opened it, saw an empty menu, and bailed — that's
661
+ // the Gen Digital "dropdown opened but no visible options found" failure.
662
+ // Detect Ashby's widget and route to type-then-wait-for-async-options.
663
+ const isAshbyCombo = await triggerLocator.evaluate((el) => {
664
+ const role = el.getAttribute('role');
665
+ const hasPopup = el.getAttribute('aria-haspopup');
666
+ const cls = String(el.className || '');
667
+ // Ashby's emotion-hashed input class, or the generic combobox+listbox shape.
668
+ const ashbyClass = /_input_/.test(cls) && !!el.closest('[class*="_fieldEntry_"], .ashby-application-form-field-entry');
669
+ return (role === 'combobox' && (hasPopup === 'listbox' || hasPopup === 'true')) || ashbyClass;
670
+ }).catch(() => false);
671
+ if (isAshbyCombo) {
672
+ const r = await typeAndPickSuggestion(root, triggerLocator, value);
673
+ if (r.ok) return r;
674
+ // If typeahead didn't land, fall through to the generic path below.
675
+ }
676
+ } catch {}
677
+
678
+ try {
679
+ // Workday combobox / multi-select. Workday wraps every form widget in a
680
+ // [data-automation-id^="formField-"] container and its dropdowns mount a
681
+ // popover with [data-automation-id="activeListContainer"], whose options
682
+ // are [data-automation-id="promptOption"] entries. Long lists (country,
683
+ // state, university) are virtualized: scrolling triggers more entries to
684
+ // render. We borrow the scroll-and-scrape pattern from the public
685
+ // shanjilcoding/resume-tailor-public/src/lib/workday.js bookmarklet.
686
+ const isWorkdayCombo = await triggerLocator.evaluate((el) => {
687
+ const inside = !!el.closest('[data-automation-id^="formField-"]');
688
+ const onWorkday = /myworkdayjobs\.com|workday\.com/i.test(window.location.href);
689
+ return inside || onWorkday;
690
+ }).catch(() => false);
691
+ if (isWorkdayCombo) {
692
+ const r = await pickWorkdayOption(root, triggerLocator, value);
693
+ if (r && r.ok) return r;
694
+ // If it failed (not actually a Workday combobox after all), fall through.
695
+ }
696
+ } catch {}
697
+
480
698
  // Second: Google Places typeahead. The Greenhouse Location field is
481
699
  // marked role=combobox in AX, so the planner sends click_option — but
482
700
  // it's really a type-then-pick autocomplete that opens a .pac-container
@@ -725,13 +943,33 @@ async function typeAndPickSuggestion(root, locator, value) {
725
943
  * @returns {Promise<{filled, skipped, failed, askUserReasons, planned}>}
726
944
  */
727
945
  async function smartFillPage(page, aep, options) {
728
- const { config, jobId, resumePath, coverLetterPath, ctx } = options;
946
+ const { config, jobId, resumePath, coverLetterPath, ctx, ats } = options;
729
947
 
730
- // 1. Scan via AX tree
731
- const fields = await scanAccessibility(page).catch((e) => {
732
- console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
733
- return [];
734
- });
948
+ // 1. Scan via AX tree. For Workday, prefer the DOM-walker that targets
949
+ // [data-automation-id^="formField-"] the AX-tree scan returns mostly
950
+ // nothing on Workday's shadow-DOM React, which is what historically forced
951
+ // the entire job into vision (expensive). If scanWorkday returns <5 fields
952
+ // (atypical posting, login wall) we fall through to scanAccessibility so
953
+ // the orchestrator's downstream vision precision-fill still gets a shot.
954
+ const isWorkdayAts = (String(ats || '').toLowerCase() === 'workday')
955
+ || /myworkdayjobs\.com|workday\.com/i.test(page.url());
956
+ let fields = [];
957
+ if (isWorkdayAts) {
958
+ const wd = await scanWorkday(page).catch((e) => {
959
+ console.warn(`[smartFill] scanWorkday failed: ${e.message}`);
960
+ return [];
961
+ });
962
+ if (wd.length >= 5) {
963
+ fields = wd;
964
+ console.log(`[scanWorkday] ${fields.length} fields detected via data-automation-id`);
965
+ }
966
+ }
967
+ if (fields.length === 0) {
968
+ fields = await scanAccessibility(page).catch((e) => {
969
+ console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
970
+ return [];
971
+ });
972
+ }
735
973
  if (fields.length === 0) {
736
974
  console.warn('[smartFill] no fields detected on page');
737
975
  return { filled: 0, skipped: 0, failed: 0, askUserReasons: [], planned: 0, fallback: false };
@@ -776,12 +1014,37 @@ async function smartFillPage(page, aep, options) {
776
1014
 
777
1015
  console.log(`[smartFill] planner returned ${plan.length} actions for ${fields.length} fields`);
778
1016
 
1017
+ // Phase 4 of the Workday end-to-end fix: dry-run preview, Workday only.
1018
+ // Workday forms are LONG (often 30+ fields across multiple pages) and the
1019
+ // cost of a wrong fill — especially on demographic / sponsorship / clearance
1020
+ // fields where the form rejects misclicks — is high. We render every
1021
+ // planned action as a single readable line and hold 8 seconds before
1022
+ // touching the DOM, letting the user Ctrl-C if anything looks off.
1023
+ // Off-switch: config.workdayDryRun = false in ~/.halo-agent/config.json.
1024
+ // Greenhouse / Lever / Ashby do NOT get this gate — their forms are short
1025
+ // enough that the existing post-fill REVIEWING gate covers it.
1026
+ if (isWorkdayAts && config?.workdayDryRun !== false) {
1027
+ console.log('');
1028
+ console.log(`[dry-run] Workday — about to fill ${plan.length} fields. Ctrl-C in the next 8s to cancel.`);
1029
+ const planByMmidPreview = new Map(plan.map((p) => [String(p.mmid), p]));
1030
+ for (const f of fields) {
1031
+ const it = planByMmidPreview.get(String(f.mmid));
1032
+ if (!it || it.action === 'skip') continue;
1033
+ const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
1034
+ const valShort = String(it.value || '').slice(0, 60).replace(/\s+/g, ' ');
1035
+ console.log(` · ${it.action.padEnd(13)} ${labelShort.padEnd(50)} ${valShort}`);
1036
+ }
1037
+ console.log('');
1038
+ await page.waitForTimeout(8000);
1039
+ }
1040
+
779
1041
  // 4. Execute the plan in DOM order (planner doesn't dictate order; we
780
1042
  // mirror the field scan order so dependent fields fill after their parents).
781
1043
  const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
782
1044
  const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
783
1045
  let filled = 0, skipped = 0, failed = 0;
784
- let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
1046
+ let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
1047
+ let coverLetterUploaded = false; // so the receipt only claims a cover letter if one actually went in
785
1048
  const askUserReasons = [];
786
1049
 
787
1050
  for (const f of fields) {
@@ -820,11 +1083,14 @@ async function smartFillPage(page, aep, options) {
820
1083
  } else {
821
1084
  console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
822
1085
  filled += 1;
823
- // Note a successful resume upload so the orchestrator doesn't run its
824
- // legacy blanket "upload resume to the first file input" fallback,
825
- // which was dumping the resume into the COVER-LETTER slot too.
826
- if (item.action === 'upload_file' && String(item.value).toLowerCase().trim() !== 'cover_letter') {
827
- resumeUploaded = true;
1086
+ // Track which files actually went in. resumeUploaded gates the
1087
+ // orchestrator's blanket fallback; coverLetterUploaded gates whether
1088
+ // the RECEIPT is allowed to claim a cover letter was submitted (it was
1089
+ // lying before packet had a cover-letter key, upload FAILED, receipt
1090
+ // still showed it).
1091
+ if (item.action === 'upload_file') {
1092
+ if (String(item.value).toLowerCase().trim() === 'cover_letter') coverLetterUploaded = true;
1093
+ else resumeUploaded = true;
828
1094
  }
829
1095
  // Track in ctx for cross-page dedup
830
1096
  if (ctx && ctx.answeredFields) {
@@ -843,7 +1109,7 @@ async function smartFillPage(page, aep, options) {
843
1109
  }
844
1110
  }
845
1111
 
846
- return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
1112
+ return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded, coverLetterUploaded };
847
1113
  }
848
1114
 
849
1115
  module.exports = { smartFillPage };