halo-agent 1.1.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/README.md +43 -0
- package/browser.js +157 -0
- package/captcha.js +217 -0
- package/config.js +37 -0
- package/filler.js +987 -0
- package/index.js +360 -0
- package/localServer.js +270 -0
- package/manusAutomate.js +349 -0
- package/orchestrator.js +1122 -0
- package/package.json +49 -0
- package/poller.js +172 -0
- package/scanPage.js +606 -0
- package/vision.js +398 -0
package/orchestrator.js
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Core state machine for auto-applying to a single job.
|
|
5
|
+
* States: ANALYZING -> FILLING -> PAGINATING -> REVIEWING -> SUBMITTING -> DONE | NEEDS_HUMAN
|
|
6
|
+
*
|
|
7
|
+
* The orchestrator never opens a fake browser — it always uses the user's real Chrome
|
|
8
|
+
* via CDP (connectOverCDP), so bot detection is not triggered.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { fillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
|
|
15
|
+
const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
|
|
16
|
+
const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
|
|
17
|
+
|
|
18
|
+
// ATS types that need vision fallback due to shadow DOM / canvas
|
|
19
|
+
const VISION_ATS = new Set(['workday', 'icims', 'taleo', 'sap', 'successfactors']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a fresh FormContext for tracking state across a multi-page application.
|
|
23
|
+
* The context accumulates what was filled, what was skipped, and what conditional
|
|
24
|
+
* fields appeared as a result of previous answers.
|
|
25
|
+
*/
|
|
26
|
+
function createFormContext() {
|
|
27
|
+
return {
|
|
28
|
+
pagesVisited: [], // [{pageIndex, url, fieldsFound, fieldsFilled}]
|
|
29
|
+
answeredFields: new Map(), // label -> {value, pageIndex, source}
|
|
30
|
+
skippedFields: [], // [{label, reason}]
|
|
31
|
+
conditionalTriggers: [], // [{triggerLabel, triggerValue, appearedLabel}]
|
|
32
|
+
currentPageIndex: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run the full apply flow for one queued job item.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} queueItem - from GET /apply-queue/next
|
|
40
|
+
* @param {object} chromeConn - from browser.connectToChrome()
|
|
41
|
+
* @param {object} config - from ~/.halo-agent/config.json
|
|
42
|
+
* @param {function} reportStatus - async fn(status, extra) to call HALO backend
|
|
43
|
+
*/
|
|
44
|
+
async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
45
|
+
const { id: queueId, job_id: jobId, apply_url, ats_type } = queueItem;
|
|
46
|
+
const apiKey = config.captchaApiKey || null;
|
|
47
|
+
const anthropicKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
|
|
48
|
+
const typingSpeed = config.typingSpeed || 'normal';
|
|
49
|
+
const useVision = config.useVisionFallback !== false;
|
|
50
|
+
|
|
51
|
+
let page = null;
|
|
52
|
+
let tempResumeFile = null;
|
|
53
|
+
|
|
54
|
+
// Initialize cross-page context tracker
|
|
55
|
+
const ctx = createFormContext();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// STEP 1: ANALYZING
|
|
59
|
+
await reportStatus('IN_PROGRESS', { status: 'IN_PROGRESS' });
|
|
60
|
+
console.log(`[orchestrator] Analyzing: ${queueItem.company} - ${queueItem.title}`);
|
|
61
|
+
|
|
62
|
+
// Fetch the full AEP from HALO backend
|
|
63
|
+
const aep = await fetchAEP(config.apiUrl, config.token, jobId);
|
|
64
|
+
if (!aep) throw new Error('Could not fetch application packet from HALO backend');
|
|
65
|
+
|
|
66
|
+
// Download resume PDF to temp file if available
|
|
67
|
+
if (aep.recommended_resume?.pdf_presigned_url) {
|
|
68
|
+
tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check for an existing checkpoint — if a previous run got past page 1
|
|
72
|
+
// before crashing, resume from where it stopped. The fillFields path uses
|
|
73
|
+
// ctx.answeredFields to skip already-completed labels, so restoring the
|
|
74
|
+
// map is enough to make the next page fill skip its predecessors' fields.
|
|
75
|
+
const checkpoint = await fetchCheckpoint(config, queueId);
|
|
76
|
+
const resuming = checkpoint && checkpoint.page_index > 0 && checkpoint.last_url;
|
|
77
|
+
|
|
78
|
+
// Open new tab to the application URL (or the checkpoint URL when resuming)
|
|
79
|
+
const url = resuming ? checkpoint.last_url : (apply_url || aep.job.apply_url || aep.job.url);
|
|
80
|
+
if (!url) throw new Error('No application URL for this job');
|
|
81
|
+
page = await chromeConn.newPage(url);
|
|
82
|
+
await waitForStableDOM(page, 3000); // let page fully render
|
|
83
|
+
|
|
84
|
+
if (resuming) {
|
|
85
|
+
const restoredCount = Object.keys(checkpoint.fields_filled || {}).length;
|
|
86
|
+
console.log(`[orchestrator] Resuming from checkpoint: page ${checkpoint.page_index}, ${restoredCount} fields already filled`);
|
|
87
|
+
for (const [label, value] of Object.entries(checkpoint.fields_filled || {})) {
|
|
88
|
+
ctx.answeredFields.set(label, { value, pageIndex: checkpoint.page_index, source: 'checkpoint' });
|
|
89
|
+
}
|
|
90
|
+
ctx.currentPageIndex = checkpoint.page_index + 1;
|
|
91
|
+
await reportStatus('IN_PROGRESS', {
|
|
92
|
+
step: 'FILLING',
|
|
93
|
+
step_detail: `Resuming page ${checkpoint.page_index + 1} (${restoredCount} fields restored)`,
|
|
94
|
+
fields_filled: restoredCount,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If we landed on a job description page (not a form), click Apply.
|
|
99
|
+
// May return a new page object if Apply opened a new tab.
|
|
100
|
+
page = await clickApplyIfNeeded(page);
|
|
101
|
+
|
|
102
|
+
// STEP 2: Vision gate detection — handles modals, resume prompts, login walls, preamble screens.
|
|
103
|
+
// Runs before captcha check so the form is actually visible before we try to fill it.
|
|
104
|
+
const gateResult = await handlePageGate(page, aep, tempResumeFile, config);
|
|
105
|
+
if (!gateResult.handled && gateResult.gateType === 'login') {
|
|
106
|
+
throw new Error('Login wall encountered — cannot auto-proceed. Please log in to this ATS in Chrome and re-queue the job.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// STEP 4: CAPTCHA CHECK before filling
|
|
110
|
+
await handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config);
|
|
111
|
+
|
|
112
|
+
// STEP 5: FILLING
|
|
113
|
+
console.log(`[orchestrator] Filling fields...`);
|
|
114
|
+
const useVisionForThis = useVision && VISION_ATS.has((ats_type || '').toLowerCase());
|
|
115
|
+
|
|
116
|
+
let fillResult;
|
|
117
|
+
if (useVisionForThis && anthropicKey) {
|
|
118
|
+
console.log(`[orchestrator] Using vision fallback for ${ats_type}`);
|
|
119
|
+
fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
|
|
120
|
+
} else {
|
|
121
|
+
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fetch AI answers for any fields that scanPage identified as needing AI
|
|
125
|
+
const needsAI = fillResult.needsAI || [];
|
|
126
|
+
if (needsAI.length > 0) {
|
|
127
|
+
console.log(`[orchestrator] Fetching AI answers for ${needsAI.length} custom field(s)...`);
|
|
128
|
+
const previouslyAnswered = Array.from(ctx.answeredFields.entries()).map(([label, data]) => ({ label, value: data.value }));
|
|
129
|
+
for (const f of needsAI) {
|
|
130
|
+
try {
|
|
131
|
+
const ans = await fetchFieldAnswer(config.apiUrl, config.token, {
|
|
132
|
+
job_id: jobId,
|
|
133
|
+
field_label: f.label,
|
|
134
|
+
field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text',
|
|
135
|
+
previously_answered: previouslyAnswered,
|
|
136
|
+
});
|
|
137
|
+
if (ans?.value) {
|
|
138
|
+
aep.field_answers = aep.field_answers || [];
|
|
139
|
+
aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
|
|
140
|
+
previouslyAnswered.push({ label: f.label, value: ans.value });
|
|
141
|
+
console.log(`[orchestrator] Got answer for "${f.label}": ${String(ans.value).slice(0, 80)}...`);
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.warn(`[orchestrator] Could not fetch answer for "${f.label}": ${e.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Second fill pass with newly fetched answers
|
|
148
|
+
const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
149
|
+
fillResult.filled += retryResult.filled;
|
|
150
|
+
fillResult.skipped = retryResult.skipped;
|
|
151
|
+
console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// If DOM filling still missed fields, use vision as precision fill
|
|
155
|
+
if (!useVisionForThis && anthropicKey && (fillResult.skipped > 2 || fillResult.failed > 0)) {
|
|
156
|
+
console.log(`[orchestrator] DOM fill left ${fillResult.skipped} skipped / ${fillResult.failed} failed — running vision precision fill...`);
|
|
157
|
+
const precisionResult = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
|
|
158
|
+
if (precisionResult.filled > 0) {
|
|
159
|
+
fillResult.filled += precisionResult.filled;
|
|
160
|
+
console.log(`[orchestrator] Vision precision fill recovered ${precisionResult.filled} fields`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Upload resume if we have a temp file
|
|
165
|
+
if (tempResumeFile) {
|
|
166
|
+
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
167
|
+
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await reportStatus('IN_PROGRESS', {
|
|
171
|
+
fields_filled: fillResult.filled || 0,
|
|
172
|
+
step: 'FILLING',
|
|
173
|
+
step_detail: `Page 1 — ${fillResult.filled || 0} fields filled`,
|
|
174
|
+
});
|
|
175
|
+
// Persist progress so a crash before pagination doesn't lose page-1 work
|
|
176
|
+
await saveCheckpoint(config, queueId, buildCheckpoint(ctx, page, ats_type));
|
|
177
|
+
|
|
178
|
+
// STEP 4: PAGINATION (multi-page forms)
|
|
179
|
+
let paginationAttempts = 0;
|
|
180
|
+
let cumulativeFilled = fillResult.filled || 0;
|
|
181
|
+
|
|
182
|
+
while (paginationAttempts < 10) {
|
|
183
|
+
await handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config);
|
|
184
|
+
|
|
185
|
+
const submitBtn = await findSubmitButton(page);
|
|
186
|
+
if (submitBtn) break; // reached review/submit page
|
|
187
|
+
|
|
188
|
+
const nextBtn = await findNextButton(page);
|
|
189
|
+
if (!nextBtn) {
|
|
190
|
+
// No next and no submit — wait for dynamic forms to settle
|
|
191
|
+
await waitForStableDOM(page, 3000);
|
|
192
|
+
const retrySubmit = await findSubmitButton(page);
|
|
193
|
+
if (retrySubmit) break;
|
|
194
|
+
// Check if URL changed (some ATS redirect to confirmation)
|
|
195
|
+
const currentUrl = page.url();
|
|
196
|
+
const looksLikeConfirmation = /thank|confirm|success|applied|submitted/i.test(currentUrl);
|
|
197
|
+
if (looksLikeConfirmation) {
|
|
198
|
+
await reportStatus('DONE', { fields_filled: cumulativeFilled });
|
|
199
|
+
await clearCheckpoint(config, queueId);
|
|
200
|
+
console.log(`[orchestrator] Detected confirmation page by URL: ${currentUrl}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
console.log(`[orchestrator] No next/submit button found — stopping pagination at attempt ${paginationAttempts}`);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log(`[orchestrator] Navigating to next page (attempt ${paginationAttempts + 1})...`);
|
|
208
|
+
|
|
209
|
+
// Snapshot visible fields BEFORE clicking next (for conditional field detection)
|
|
210
|
+
const fieldsBefore = await snapshotFieldLabels(page);
|
|
211
|
+
|
|
212
|
+
await nextBtn.click();
|
|
213
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
214
|
+
await waitForStableDOM(page, 3000); // smart wait — stops as soon as form stabilizes
|
|
215
|
+
|
|
216
|
+
// Snapshot AFTER navigation to detect newly appeared conditional fields
|
|
217
|
+
const fieldsAfter = await snapshotFieldLabels(page);
|
|
218
|
+
const newConditionalFields = [...fieldsAfter].filter(label => label && !fieldsBefore.has(label));
|
|
219
|
+
|
|
220
|
+
if (newConditionalFields.length > 0) {
|
|
221
|
+
console.log(`[orchestrator] Detected ${newConditionalFields.length} conditional field(s): ${newConditionalFields.slice(0, 3).join(', ')}`);
|
|
222
|
+
|
|
223
|
+
// Fetch answers for conditional fields from backend with cross-page context
|
|
224
|
+
const previouslyAnswered = Array.from(ctx.answeredFields.entries()).map(([label, data]) => ({
|
|
225
|
+
label,
|
|
226
|
+
value: data.value,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
for (const condLabel of newConditionalFields) {
|
|
230
|
+
// Check if it's already in the AEP answers (may have been pre-generated)
|
|
231
|
+
const existing = (aep.field_answers || []).find(fa =>
|
|
232
|
+
fa.label && fa.label.toLowerCase().trim() === condLabel.toLowerCase().trim()
|
|
233
|
+
);
|
|
234
|
+
if (existing) continue; // already have the answer in AEP, fillFields will handle it
|
|
235
|
+
|
|
236
|
+
// Fetch a fresh answer from backend with context
|
|
237
|
+
try {
|
|
238
|
+
const condAnswer = await fetchFieldAnswer(config.apiUrl, config.token, {
|
|
239
|
+
job_id: jobId,
|
|
240
|
+
field_label: condLabel,
|
|
241
|
+
field_type: 'input',
|
|
242
|
+
previously_answered: previouslyAnswered,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (condAnswer?.value) {
|
|
246
|
+
// Inject into AEP so fillFields can pick it up
|
|
247
|
+
aep.field_answers = aep.field_answers || [];
|
|
248
|
+
aep.field_answers.push({
|
|
249
|
+
field_id: condLabel,
|
|
250
|
+
label: condLabel,
|
|
251
|
+
value: condAnswer.value,
|
|
252
|
+
confidence: condAnswer.confidence || 0.75,
|
|
253
|
+
source: condAnswer.source || 'conditional',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Track conditional trigger
|
|
257
|
+
ctx.conditionalTriggers.push({
|
|
258
|
+
appeared_label: condLabel,
|
|
259
|
+
page_index: ctx.currentPageIndex,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.warn(`[orchestrator] Could not fetch conditional answer for "${condLabel}": ${e.message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
ctx.currentPageIndex++;
|
|
269
|
+
|
|
270
|
+
// Fill any new fields that appeared on this page
|
|
271
|
+
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
272
|
+
cumulativeFilled += pageResult.filled || 0;
|
|
273
|
+
|
|
274
|
+
// Fetch AI answers for custom fields scanPage identified on this page
|
|
275
|
+
const pageNeedsAI = pageResult.needsAI || [];
|
|
276
|
+
if (pageNeedsAI.length > 0) {
|
|
277
|
+
const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([l, d]) => ({ label: l, value: d.value }));
|
|
278
|
+
for (const f of pageNeedsAI) {
|
|
279
|
+
try {
|
|
280
|
+
const ans = await fetchFieldAnswer(config.apiUrl, config.token, { job_id: jobId, field_label: f.label, field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text', previously_answered: prevAnswered });
|
|
281
|
+
if (ans?.value) {
|
|
282
|
+
aep.field_answers = aep.field_answers || [];
|
|
283
|
+
aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
|
|
284
|
+
prevAnswered.push({ label: f.label, value: ans.value });
|
|
285
|
+
}
|
|
286
|
+
} catch {}
|
|
287
|
+
}
|
|
288
|
+
const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
289
|
+
cumulativeFilled += retryPage.filled || 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Precision vision fill for missed fields on this page too
|
|
293
|
+
if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
|
|
294
|
+
const precisionResult = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
|
|
295
|
+
cumulativeFilled += precisionResult.filled || 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await reportStatus('IN_PROGRESS', {
|
|
299
|
+
fields_filled: cumulativeFilled,
|
|
300
|
+
step: 'PAGINATING',
|
|
301
|
+
step_detail: `Page ${paginationAttempts + 2} — ${cumulativeFilled} fields filled`,
|
|
302
|
+
});
|
|
303
|
+
// Persist after each page so a crash mid-flow only loses the current page,
|
|
304
|
+
// not the whole application. Backend caps at 32KB; ctx.answeredFields is
|
|
305
|
+
// a label→value map so the payload grows linearly with form complexity.
|
|
306
|
+
await saveCheckpoint(config, queueId, buildCheckpoint(ctx, page, ats_type));
|
|
307
|
+
paginationAttempts++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// STEP 5: REVIEWING
|
|
311
|
+
let submitBtn = await findSubmitButton(page);
|
|
312
|
+
|
|
313
|
+
if (!submitBtn && anthropicKey) {
|
|
314
|
+
// DOM couldn't find submit — hand off to Claude vision to navigate to submit page
|
|
315
|
+
console.log(`[orchestrator] Submit button not found via DOM. Trying vision fallback...`);
|
|
316
|
+
const visionResult = await visionNavigateAndSubmit(page, aep, anthropicKey, {
|
|
317
|
+
autoSubmit: config.autoSubmit || false,
|
|
318
|
+
alreadyFilled: ctx.answeredFields,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
if (visionResult.submitted) {
|
|
322
|
+
// Vision already submitted — we're done
|
|
323
|
+
const confirmShot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
324
|
+
const confirmKey = confirmShot ? await uploadScreenshot(config, confirmShot, `confirm_${queueId}.jpg`) : null;
|
|
325
|
+
await reportStatus('DONE', {
|
|
326
|
+
confirmation_screenshot_r2_key: confirmKey || null,
|
|
327
|
+
fields_filled: cumulativeFilled,
|
|
328
|
+
});
|
|
329
|
+
await clearCheckpoint(config, queueId);
|
|
330
|
+
console.log(`[orchestrator] Done via vision: ${queueItem.company} - ${queueItem.title}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Vision navigated to review page but didn't submit — try DOM submit one more time
|
|
335
|
+
submitBtn = await findSubmitButton(page);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!submitBtn) {
|
|
339
|
+
const pageTitle = await page.title().catch(() => 'unknown');
|
|
340
|
+
const pageUrl = page.url();
|
|
341
|
+
throw new Error(`Could not find submit button. Page: "${pageTitle}" (${pageUrl})`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
|
|
345
|
+
|
|
346
|
+
// Take a screenshot of the review page
|
|
347
|
+
const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
348
|
+
const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
|
|
349
|
+
|
|
350
|
+
await reportStatus('REVIEWING', {
|
|
351
|
+
review_screenshot_r2_key: reviewKey || null,
|
|
352
|
+
step: 'REVIEWING',
|
|
353
|
+
step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Wait for user to confirm submission from dashboard
|
|
357
|
+
// OR auto-submit if config.autoSubmit is true
|
|
358
|
+
if (config.autoSubmit || aep.agent_config?.auto_submit) {
|
|
359
|
+
const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
|
|
360
|
+
await page.waitForTimeout(timeout);
|
|
361
|
+
} else {
|
|
362
|
+
await waitForSubmitConfirmation(config, queueId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// STEP 6: SUBMITTING
|
|
366
|
+
console.log(`[orchestrator] Submitting application...`);
|
|
367
|
+
await reportStatus('IN_PROGRESS', {
|
|
368
|
+
step: 'SUBMITTING',
|
|
369
|
+
step_detail: 'Clicking submit',
|
|
370
|
+
fields_filled: cumulativeFilled,
|
|
371
|
+
});
|
|
372
|
+
await submitBtn.click();
|
|
373
|
+
|
|
374
|
+
// Wait for confirmation page
|
|
375
|
+
try {
|
|
376
|
+
await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 15000 });
|
|
377
|
+
} catch {
|
|
378
|
+
await page.waitForTimeout(3000); // fallback wait
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const confirmScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
382
|
+
const confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
|
|
383
|
+
|
|
384
|
+
await reportStatus('DONE', {
|
|
385
|
+
confirmation_screenshot_r2_key: confirmKey || null,
|
|
386
|
+
fields_filled: cumulativeFilled,
|
|
387
|
+
});
|
|
388
|
+
await clearCheckpoint(config, queueId);
|
|
389
|
+
|
|
390
|
+
console.log(`[orchestrator] Done: ${queueItem.company} - ${queueItem.title}`);
|
|
391
|
+
|
|
392
|
+
// Post fill session data to backend for learning loop
|
|
393
|
+
await postFillSession(config, {
|
|
394
|
+
job_id: jobId,
|
|
395
|
+
ats_type: ats_type || 'unknown',
|
|
396
|
+
pages_visited: ctx.currentPageIndex + 1,
|
|
397
|
+
fields_filled: cumulativeFilled,
|
|
398
|
+
skipped_fields: ctx.skippedFields,
|
|
399
|
+
conditional_fields: ctx.conditionalTriggers,
|
|
400
|
+
}).catch(() => {}); // non-critical
|
|
401
|
+
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error(`[orchestrator] Error:`, err.message);
|
|
404
|
+
// Classify the failure into one of the InterventionPanel types so the user
|
|
405
|
+
// sees the right instructions rather than a raw stack trace.
|
|
406
|
+
const msg = (err.message || '').toLowerCase();
|
|
407
|
+
let interventionType = 'generic';
|
|
408
|
+
if (/timeout|timed.out|deadline/.test(msg)) interventionType = 'timeout';
|
|
409
|
+
else if (/login|sign.?in|auth.?required|please.+log.?in/.test(msg)) interventionType = 'login_wall';
|
|
410
|
+
await reportStatus('NEEDS_ATTENTION', {
|
|
411
|
+
needs_attention_reason: err.message,
|
|
412
|
+
intervention_type: interventionType,
|
|
413
|
+
step: 'NEEDS_ATTENTION',
|
|
414
|
+
step_detail: err.message?.slice(0, 120),
|
|
415
|
+
}).catch(() => {});
|
|
416
|
+
} finally {
|
|
417
|
+
// Clean up temp resume file
|
|
418
|
+
if (tempResumeFile) {
|
|
419
|
+
try { fs.unlinkSync(tempResumeFile); } catch {}
|
|
420
|
+
}
|
|
421
|
+
// Close the tab (leave other tabs untouched)
|
|
422
|
+
if (page) {
|
|
423
|
+
try { await page.close(); } catch {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function handleCaptchaIfPresent(page, apiKey, queueId, reportStatus, config) {
|
|
429
|
+
const captcha = await detectCaptcha(page);
|
|
430
|
+
if (!captcha.detected) return;
|
|
431
|
+
|
|
432
|
+
console.log(`[orchestrator] CAPTCHA detected: ${captcha.type}`);
|
|
433
|
+
|
|
434
|
+
if (apiKey && captcha.sitekey) {
|
|
435
|
+
console.log(`[orchestrator] Attempting CapSolver...`);
|
|
436
|
+
const token = await solveCaptcha(captcha, apiKey);
|
|
437
|
+
if (token) {
|
|
438
|
+
await injectCaptchaToken(page, token, captcha.type);
|
|
439
|
+
await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
|
|
440
|
+
console.log(`[orchestrator] CAPTCHA solved.`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Could not solve — pause for human
|
|
446
|
+
await reportStatus('NEEDS_ATTENTION', {
|
|
447
|
+
captcha_triggered: 1,
|
|
448
|
+
captcha_solved: 0,
|
|
449
|
+
needs_attention_reason: `CAPTCHA appeared (${captcha.type}). Please solve it in Chrome and click "Resume" in the dashboard.`,
|
|
450
|
+
intervention_type: 'captcha',
|
|
451
|
+
step: 'CAPTCHA',
|
|
452
|
+
step_detail: `${captcha.type} CAPTCHA — awaiting your solve`,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Wait for human to resolve (poll submit-confirmed endpoint as a resume signal)
|
|
456
|
+
for (let i = 0; i < 150; i++) { // up to 5 minutes
|
|
457
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
458
|
+
// Check if user clicked "Resume" in dashboard (sets KV flag)
|
|
459
|
+
try {
|
|
460
|
+
const flagRes = await fetch(`${config.apiUrl}/apply-queue/captcha-resolved/${queueId}`, {
|
|
461
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
462
|
+
});
|
|
463
|
+
if (flagRes.ok) {
|
|
464
|
+
const flagData = await flagRes.json();
|
|
465
|
+
if (flagData.resolved) {
|
|
466
|
+
await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch {}
|
|
471
|
+
// Also re-check if CAPTCHA is gone from the page (human solved it directly)
|
|
472
|
+
const stillThere = await detectCaptcha(page);
|
|
473
|
+
if (!stillThere.detected) {
|
|
474
|
+
await reportStatus('IN_PROGRESS', { captcha_triggered: 1, captcha_solved: 1 });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
throw new Error('CAPTCHA not solved within timeout. Application paused.');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function fetchAEP(apiUrl, token, jobId) {
|
|
483
|
+
try {
|
|
484
|
+
const res = await fetch(`${apiUrl}/smartfill/agent-packet/${jobId}`, {
|
|
485
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
486
|
+
});
|
|
487
|
+
if (!res.ok) return null;
|
|
488
|
+
return res.json();
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Checkpoint helpers ───────────────────────────────────────────────────────
|
|
495
|
+
// Persist what the agent has already filled so a crashed/restarted agent can
|
|
496
|
+
// resume from the last completed page. Saved JSON is small (label→value map
|
|
497
|
+
// for the current job's pages), bounded by the backend at 32KB.
|
|
498
|
+
async function fetchCheckpoint(config, queueId) {
|
|
499
|
+
try {
|
|
500
|
+
const res = await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
|
|
501
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
502
|
+
});
|
|
503
|
+
if (!res.ok) return null;
|
|
504
|
+
const data = await res.json();
|
|
505
|
+
return data.checkpoint || null;
|
|
506
|
+
} catch { return null; }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function saveCheckpoint(config, queueId, checkpoint) {
|
|
510
|
+
try {
|
|
511
|
+
await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
|
|
512
|
+
method: 'PATCH',
|
|
513
|
+
headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
|
|
514
|
+
body: JSON.stringify(checkpoint),
|
|
515
|
+
});
|
|
516
|
+
} catch { /* fire-and-forget; non-critical */ }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function clearCheckpoint(config, queueId) {
|
|
520
|
+
try {
|
|
521
|
+
await fetch(`${config.apiUrl}/agent/queue/${queueId}/checkpoint`, {
|
|
522
|
+
method: 'DELETE',
|
|
523
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
524
|
+
});
|
|
525
|
+
} catch { /* fire-and-forget */ }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Build a checkpoint snapshot from the current FormContext. Flattens
|
|
529
|
+
// answeredFields (a Map of label -> {value, ...}) into label -> value.
|
|
530
|
+
function buildCheckpoint(ctx, page, atsType) {
|
|
531
|
+
const fields_filled = {};
|
|
532
|
+
for (const [label, data] of ctx.answeredFields.entries()) {
|
|
533
|
+
if (data && typeof data.value === 'string') {
|
|
534
|
+
fields_filled[label] = data.value;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
page_index: ctx.currentPageIndex,
|
|
539
|
+
fields_filled,
|
|
540
|
+
pages_remaining: null,
|
|
541
|
+
ats_type: atsType || null,
|
|
542
|
+
last_url: page ? page.url() : '',
|
|
543
|
+
saved_at: new Date().toISOString(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function fetchFieldAnswer(apiUrl, token, body) {
|
|
548
|
+
try {
|
|
549
|
+
const res = await fetch(`${apiUrl}/smartfill/field-answer`, {
|
|
550
|
+
method: 'POST',
|
|
551
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
552
|
+
body: JSON.stringify(body),
|
|
553
|
+
});
|
|
554
|
+
if (!res.ok) return null;
|
|
555
|
+
return res.json();
|
|
556
|
+
} catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function postFillSession(config, data) {
|
|
562
|
+
try {
|
|
563
|
+
await fetch(`${config.apiUrl}/smartfill/session`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: { Authorization: `Bearer ${config.token}`, 'Content-Type': 'application/json' },
|
|
566
|
+
body: JSON.stringify(data),
|
|
567
|
+
});
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function downloadResume(presignedUrl) {
|
|
572
|
+
try {
|
|
573
|
+
const res = await fetch(presignedUrl);
|
|
574
|
+
if (!res.ok) return null;
|
|
575
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
576
|
+
const tmpPath = path.join(os.tmpdir(), `halo_resume_${Date.now()}.pdf`);
|
|
577
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
578
|
+
return tmpPath;
|
|
579
|
+
} catch {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function uploadScreenshot(config, screenshotBuffer, filename) {
|
|
585
|
+
// Upload to HALO backend which stores in R2
|
|
586
|
+
try {
|
|
587
|
+
const formData = new FormData();
|
|
588
|
+
const blob = new Blob([screenshotBuffer], { type: 'image/jpeg' });
|
|
589
|
+
formData.append('file', blob, filename);
|
|
590
|
+
const res = await fetch(`${config.apiUrl}/agent/screenshot`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
593
|
+
body: formData,
|
|
594
|
+
});
|
|
595
|
+
if (!res.ok) return null;
|
|
596
|
+
const data = await res.json();
|
|
597
|
+
return data.r2_key || null;
|
|
598
|
+
} catch {
|
|
599
|
+
return null; // screenshots are nice-to-have, not critical
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Vision-based intermediary step handler.
|
|
605
|
+
*
|
|
606
|
+
* After each navigation, takes a screenshot and sends it to the HALO backend
|
|
607
|
+
* which runs it through Workers AI (@cf/meta/llama-3.2-11b-vision-instruct) to classify
|
|
608
|
+
* any "gate" blocking the actual form (modal, resume upload, login wall, preamble, EEOC).
|
|
609
|
+
*
|
|
610
|
+
* The AI returns {gate_type, action, button_text, confidence} and the agent executes
|
|
611
|
+
* the appropriate action: click a button, upload the resume, or skip.
|
|
612
|
+
* Loops up to 5 times to handle stacked gates (e.g. modal then resume prompt).
|
|
613
|
+
*/
|
|
614
|
+
async function handlePageGate(page, aep, resumeFile, config) {
|
|
615
|
+
// Re-run up to 4 times — some gates are stacked (modal then resume upload)
|
|
616
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
617
|
+
await waitForStableDOM(page, 2000);
|
|
618
|
+
|
|
619
|
+
// Take screenshot and POST to HALO backend for vision-based gate classification
|
|
620
|
+
let gate;
|
|
621
|
+
try {
|
|
622
|
+
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
623
|
+
const base64 = screenshot.toString('base64');
|
|
624
|
+
const pageUrl = page.url();
|
|
625
|
+
|
|
626
|
+
const res = await fetch(`${config.apiUrl}/smartfill/detect-gate`, {
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers: {
|
|
629
|
+
Authorization: `Bearer ${config.token}`,
|
|
630
|
+
'Content-Type': 'application/json',
|
|
631
|
+
},
|
|
632
|
+
body: JSON.stringify({ screenshot_base64: base64, page_url: pageUrl }),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
if (!res.ok) {
|
|
636
|
+
console.warn(`[gate] detect-gate returned ${res.status} — skipping gate check`);
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
gate = await res.json();
|
|
640
|
+
} catch (e) {
|
|
641
|
+
console.warn(`[gate] Detection request failed: ${e.message} — skipping gate check`);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
console.log(`[gate] Pass ${pass + 1}: ${gate.gate_type} (confidence: ${(gate.confidence || 0).toFixed(2)}, action: ${gate.action})`);
|
|
646
|
+
|
|
647
|
+
// No gate or low confidence — we're on the actual form
|
|
648
|
+
if (gate.gate_type === 'none' || (gate.confidence || 0) < 0.55) break;
|
|
649
|
+
|
|
650
|
+
// Login wall — cannot auto-proceed
|
|
651
|
+
if (gate.gate_type === 'login') {
|
|
652
|
+
console.warn('[gate] Login wall detected — cannot auto-proceed');
|
|
653
|
+
return { handled: false, gateType: 'login' };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Click a button (modal choice, preamble, EEOC acknowledge)
|
|
657
|
+
if (gate.action === 'click' && gate.button_text) {
|
|
658
|
+
console.log(`[gate] Clicking "${gate.button_text}"`);
|
|
659
|
+
const pattern = new RegExp(escapeRegex(gate.button_text), 'i');
|
|
660
|
+
const clicked =
|
|
661
|
+
await page.getByRole('button', { name: pattern }).first().click({ timeout: 3000 }).then(() => true).catch(() => false) ||
|
|
662
|
+
await page.getByRole('link', { name: pattern }).first().click({ timeout: 2000 }).then(() => true).catch(() => false) ||
|
|
663
|
+
await page.locator('button, a, [role="button"]').filter({ hasText: pattern }).first().click({ timeout: 2000 }).then(() => true).catch(() => false);
|
|
664
|
+
|
|
665
|
+
if (!clicked) {
|
|
666
|
+
console.warn(`[gate] Could not find/click "${gate.button_text}" — stopping gate loop`);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
await waitForStableDOM(page, 2500);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Upload resume (Ashby autofill bar, Lever drop zone, etc.)
|
|
674
|
+
if (gate.action === 'upload_resume' && resumeFile) {
|
|
675
|
+
console.log('[gate] Uploading resume to gate prompt');
|
|
676
|
+
const uploaded = await uploadResumeToPage(page, resumeFile);
|
|
677
|
+
if (uploaded) {
|
|
678
|
+
console.log('[gate] Resume uploaded successfully');
|
|
679
|
+
await waitForStableDOM(page, 3000);
|
|
680
|
+
continue;
|
|
681
|
+
} else {
|
|
682
|
+
console.warn('[gate] Resume upload failed — continuing to form fill anyway');
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Skip / unknown action — stop looping
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { handled: true, gateType: null };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Upload a resume file to the current page.
|
|
696
|
+
* Tries direct setInputFiles on hidden inputs first, then file chooser intercept.
|
|
697
|
+
*/
|
|
698
|
+
async function uploadResumeToPage(page, filePath) {
|
|
699
|
+
try {
|
|
700
|
+
// Unhide all file inputs and set files on the first one
|
|
701
|
+
const allInputs = page.locator('input[type="file"]');
|
|
702
|
+
const count = await allInputs.count();
|
|
703
|
+
for (let i = 0; i < count; i++) {
|
|
704
|
+
await allInputs.nth(i).evaluate(el => {
|
|
705
|
+
el.style.display = 'block';
|
|
706
|
+
el.style.opacity = '1';
|
|
707
|
+
el.style.visibility = 'visible';
|
|
708
|
+
el.style.width = '1px';
|
|
709
|
+
el.style.height = '1px';
|
|
710
|
+
el.style.position = 'fixed';
|
|
711
|
+
el.style.top = '0';
|
|
712
|
+
}).catch(() => {});
|
|
713
|
+
}
|
|
714
|
+
if (count > 0) {
|
|
715
|
+
await allInputs.first().setInputFiles(filePath);
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
} catch {}
|
|
719
|
+
|
|
720
|
+
// Fallback: trigger file chooser by clicking an upload button/label
|
|
721
|
+
try {
|
|
722
|
+
const uploadBtns = page.locator('button, a, label, [role="button"]').filter({ hasText: /upload|attach|browse/i });
|
|
723
|
+
const btnCount = await uploadBtns.count();
|
|
724
|
+
if (btnCount > 0) {
|
|
725
|
+
const [chooser] = await Promise.all([
|
|
726
|
+
page.waitForEvent('filechooser', { timeout: 5000 }),
|
|
727
|
+
uploadBtns.first().click(),
|
|
728
|
+
]);
|
|
729
|
+
await chooser.setFiles(filePath);
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
} catch {}
|
|
733
|
+
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function escapeRegex(str) {
|
|
738
|
+
return (str || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Detect if we're on a job listing/description page instead of an application form.
|
|
743
|
+
* If so, find and click the Apply button to get to the actual form.
|
|
744
|
+
* Returns the page to use going forward (may be a new tab if Apply opens one).
|
|
745
|
+
*/
|
|
746
|
+
async function clickApplyIfNeeded(page) {
|
|
747
|
+
// Check if there's a visible form input — if yes, we're already on an application form
|
|
748
|
+
const hasFormFields = await page.evaluate(() => {
|
|
749
|
+
const inputs = document.querySelectorAll('input[type="text"], input[type="email"], input[type="tel"], textarea');
|
|
750
|
+
return inputs.length > 0;
|
|
751
|
+
});
|
|
752
|
+
if (hasFormFields) return page; // already on form
|
|
753
|
+
|
|
754
|
+
// Look for an Apply button on the listing page
|
|
755
|
+
const applySelectors = [
|
|
756
|
+
'[data-automation-id="jobPostingApplyButton"]', // Workday — most specific first
|
|
757
|
+
'[data-testid="apply-button"]',
|
|
758
|
+
'.apply-button',
|
|
759
|
+
'#apply-button',
|
|
760
|
+
'button:has-text("Apply Now")',
|
|
761
|
+
'a:has-text("Apply Now")',
|
|
762
|
+
'button:has-text("Apply")',
|
|
763
|
+
'a:has-text("Apply")',
|
|
764
|
+
];
|
|
765
|
+
|
|
766
|
+
for (const sel of applySelectors) {
|
|
767
|
+
try {
|
|
768
|
+
const el = page.locator(sel).first();
|
|
769
|
+
if (await el.isVisible({ timeout: 1000 })) {
|
|
770
|
+
console.log(`[orchestrator] On listing page — clicking Apply button (selector: ${sel})...`);
|
|
771
|
+
|
|
772
|
+
// Listen for a popup (new tab) BEFORE clicking
|
|
773
|
+
const popupPromise = page.context().waitForEvent('page', { timeout: 8000 }).catch(() => null);
|
|
774
|
+
await el.click();
|
|
775
|
+
|
|
776
|
+
const urlBefore = page.url();
|
|
777
|
+
|
|
778
|
+
// Check if a new tab opened
|
|
779
|
+
const newTab = await popupPromise;
|
|
780
|
+
if (newTab) {
|
|
781
|
+
console.log(`[orchestrator] Apply opened new tab: ${newTab.url()}`);
|
|
782
|
+
await newTab.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
|
|
783
|
+
await waitForStableDOM(newTab, 3000);
|
|
784
|
+
console.log(`[orchestrator] New tab settled at: ${newTab.url()}`);
|
|
785
|
+
return newTab; // caller must use this page going forward
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Same-tab navigation
|
|
789
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
|
|
790
|
+
await waitForStableDOM(page, 3000);
|
|
791
|
+
const urlAfter = page.url();
|
|
792
|
+
console.log(`[orchestrator] Post-click URL: ${urlAfter} (was: ${urlBefore})`);
|
|
793
|
+
return page;
|
|
794
|
+
}
|
|
795
|
+
} catch {}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// No apply button found — log what's on the page for diagnosis
|
|
799
|
+
const pageUrl = page.url();
|
|
800
|
+
const pageTitle = await page.title().catch(() => 'unknown');
|
|
801
|
+
console.log(`[orchestrator] No Apply button found on "${pageTitle}" (${pageUrl}) — assuming already on form`);
|
|
802
|
+
return page;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Find visible textarea/input fields on the page that have no answer in the AEP yet.
|
|
807
|
+
* These are custom essay questions the AEP packet didn't pre-generate.
|
|
808
|
+
*/
|
|
809
|
+
async function getUnmatchedFields(page, aep) {
|
|
810
|
+
const { extractPageFields } = require('./filler');
|
|
811
|
+
try {
|
|
812
|
+
const pageFields = await extractPageFields(page);
|
|
813
|
+
const answeredLabels = new Set((aep.field_answers || []).map(fa => (fa.label || '').toLowerCase().trim()));
|
|
814
|
+
|
|
815
|
+
// Only look at textarea fields and long-text inputs (essay questions)
|
|
816
|
+
const candidates = pageFields.filter(f => {
|
|
817
|
+
if (f.tag !== 'textarea' && !(f.tag === 'input' && f.type === 'text')) return false;
|
|
818
|
+
if (!f.label || f.label.length < 10) return false; // skip unlabeled / short labels
|
|
819
|
+
const norm = f.label.toLowerCase().trim();
|
|
820
|
+
// Skip if already answered
|
|
821
|
+
if (answeredLabels.has(norm)) return false;
|
|
822
|
+
// Skip common profile fields that the profile_fill map handles
|
|
823
|
+
const profileKeywords = ['first name', 'last name', 'email', 'phone', 'linkedin', 'github', 'address', 'city', 'zip', 'postal', 'salary', 'url', 'website'];
|
|
824
|
+
if (profileKeywords.some(k => norm.includes(k))) return false;
|
|
825
|
+
return true;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
return candidates.map(f => f.label);
|
|
829
|
+
} catch {
|
|
830
|
+
return [];
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function waitForSubmitConfirmation(config, queueId) {
|
|
835
|
+
console.log(`[orchestrator] Waiting for user to confirm submission from dashboard...`);
|
|
836
|
+
for (let i = 0; i < 180; i++) { // up to 15 minutes
|
|
837
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
838
|
+
try {
|
|
839
|
+
const res = await fetch(`${config.apiUrl}/apply-queue/submit-confirmed/${queueId}`, {
|
|
840
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
841
|
+
});
|
|
842
|
+
if (!res.ok) continue;
|
|
843
|
+
const data = await res.json();
|
|
844
|
+
if (data.confirmed) return;
|
|
845
|
+
} catch {}
|
|
846
|
+
}
|
|
847
|
+
throw new Error('Submission confirmation timed out. Application paused at review stage.');
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Extension-triggered fill (Architecture 4 handoff).
|
|
852
|
+
*
|
|
853
|
+
* Unlike runJob(), this does NOT open a new tab — the user is already on the
|
|
854
|
+
* application page. We attach to that tab via CDP, run the full fill pipeline,
|
|
855
|
+
* then wait for the extension's confirm signal before submitting.
|
|
856
|
+
*
|
|
857
|
+
* @param {object} opts
|
|
858
|
+
* fillId — unique ID for this fill session (used for logging)
|
|
859
|
+
* apply_url — URL of the page the user is on (used to find the tab)
|
|
860
|
+
* job_id — HALO job ID (for AEP fetch and field-answer calls)
|
|
861
|
+
* ats_type — detected ATS type from extension (e.g. 'greenhouse')
|
|
862
|
+
* packet — optional pre-generated packet from extension session storage
|
|
863
|
+
* extConfig — { apiUrl, token } from extension (may differ from agent config)
|
|
864
|
+
* chromeConn — existing CDP connection { browser, context, newPage }
|
|
865
|
+
* reportFillStatus — fn(state, extra) writes to in-memory session map
|
|
866
|
+
* waitForConfirm — Promise that resolves when user clicks Submit in extension overlay
|
|
867
|
+
*/
|
|
868
|
+
async function runExtensionFill({
|
|
869
|
+
fillId, apply_url, job_id, ats_type, packet,
|
|
870
|
+
extConfig, chromeConn, reportFillStatus, waitForConfirm,
|
|
871
|
+
}) {
|
|
872
|
+
// Load agent config to get credentials + settings
|
|
873
|
+
const { loadConfig } = require('./config');
|
|
874
|
+
const config = loadConfig();
|
|
875
|
+
|
|
876
|
+
// Prefer extension-supplied credentials (user may be on a different account)
|
|
877
|
+
const apiUrl = (extConfig && extConfig.apiUrl) || config.apiUrl;
|
|
878
|
+
const token = (extConfig && extConfig.token) || config.token;
|
|
879
|
+
const anthropicKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY || null;
|
|
880
|
+
const typingSpeed = config.typingSpeed || 'normal';
|
|
881
|
+
const useVision = config.useVisionFallback !== false;
|
|
882
|
+
|
|
883
|
+
const log = (msg) => console.log(`[ext-fill:${fillId}] ${msg}`);
|
|
884
|
+
|
|
885
|
+
reportFillStatus('ANALYZING', { message: 'Finding active Chrome tab...' });
|
|
886
|
+
log('Starting extension-triggered fill');
|
|
887
|
+
|
|
888
|
+
// Find the active tab that matches apply_url — attach to it rather than opening a new one
|
|
889
|
+
let page = null;
|
|
890
|
+
try {
|
|
891
|
+
const ctx = chromeConn.browser.contexts()[0];
|
|
892
|
+
const pages = ctx.pages();
|
|
893
|
+
// Match by URL prefix — handles redirects and hash changes
|
|
894
|
+
const applyHost = new URL(apply_url).hostname;
|
|
895
|
+
page = pages.find(p => {
|
|
896
|
+
try { return new URL(p.url()).hostname === applyHost; } catch { return false; }
|
|
897
|
+
}) || pages[pages.length - 1]; // fallback: most-recently-opened tab
|
|
898
|
+
if (!page) throw new Error('No matching Chrome tab found — make sure the application page is open');
|
|
899
|
+
} catch (e) {
|
|
900
|
+
throw new Error('Could not attach to application tab: ' + e.message);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
log(`Attached to tab: ${page.url()}`);
|
|
904
|
+
reportFillStatus('ANALYZING', { message: 'Fetching your application packet...' });
|
|
905
|
+
|
|
906
|
+
// Fetch AEP — prefer pre-built packet from extension, fall back to backend fetch
|
|
907
|
+
let aep = null;
|
|
908
|
+
if (job_id) {
|
|
909
|
+
aep = await fetchAEP(apiUrl, token, job_id);
|
|
910
|
+
}
|
|
911
|
+
if (!aep && packet) {
|
|
912
|
+
// Build a minimal AEP from the extension's session-stored packet
|
|
913
|
+
aep = {
|
|
914
|
+
profile_fill: packet.profile || {},
|
|
915
|
+
field_answers: packet._raw_answers || [],
|
|
916
|
+
cover_letter: packet.cover_letter || '',
|
|
917
|
+
sponsorship_answer: packet.sponsorship_answer || '',
|
|
918
|
+
relocation_answer: packet.relocation_answer || '',
|
|
919
|
+
salary_answer: packet.salary_answer || '',
|
|
920
|
+
job: { id: job_id || 'ext', title: '', company: '', apply_url },
|
|
921
|
+
agent_config: { auto_submit: false, review_timeout_seconds: 30, typing_speed: typingSpeed },
|
|
922
|
+
};
|
|
923
|
+
// Hydrate field_answers from short_answers_json if present
|
|
924
|
+
if (packet.short_answers_json) {
|
|
925
|
+
try {
|
|
926
|
+
const sa = JSON.parse(packet.short_answers_json);
|
|
927
|
+
aep.field_answers = sa.map((a, i) => ({
|
|
928
|
+
field_id: `sa_${i}`, label: a.question, value: a.answer, confidence: 0.85, source: 'packet',
|
|
929
|
+
}));
|
|
930
|
+
} catch {}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (!aep) throw new Error('No application packet available — generate one from the HALO dashboard first');
|
|
934
|
+
|
|
935
|
+
// Download resume if available
|
|
936
|
+
let tempResumeFile = null;
|
|
937
|
+
if (aep.recommended_resume?.pdf_presigned_url) {
|
|
938
|
+
tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const ctx = createFormContext();
|
|
942
|
+
const useVisionForThis = useVision && VISION_ATS.has((ats_type || '').toLowerCase());
|
|
943
|
+
|
|
944
|
+
// Wait for page to settle (user may have just navigated here)
|
|
945
|
+
reportFillStatus('ANALYZING', { message: 'Waiting for page to load...' });
|
|
946
|
+
await waitForStableDOM(page, 2000);
|
|
947
|
+
|
|
948
|
+
// Gate handling — same as runJob
|
|
949
|
+
reportFillStatus('ANALYZING', { message: 'Checking for page gates...' });
|
|
950
|
+
await handlePageGate(page, aep, tempResumeFile, { apiUrl, token, ...config });
|
|
951
|
+
|
|
952
|
+
// CAPTCHA check
|
|
953
|
+
const dummyQueueId = fillId;
|
|
954
|
+
const dummyReportStatus = async () => {};
|
|
955
|
+
await handleCaptchaIfPresent(page, config.captchaApiKey || null, dummyQueueId, dummyReportStatus, config);
|
|
956
|
+
|
|
957
|
+
// FILLING
|
|
958
|
+
reportFillStatus('FILLING', { message: 'Filling form fields...' });
|
|
959
|
+
log('Starting field fill');
|
|
960
|
+
|
|
961
|
+
let fillResult;
|
|
962
|
+
if (useVisionForThis && anthropicKey) {
|
|
963
|
+
log(`Vision fill for ${ats_type}`);
|
|
964
|
+
fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
|
|
965
|
+
} else {
|
|
966
|
+
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
reportFillStatus('FILLING', { fieldsFilled: fillResult.filled || 0, message: `Filled ${fillResult.filled || 0} fields...` });
|
|
970
|
+
|
|
971
|
+
// AI answers for custom fields
|
|
972
|
+
const needsAI = fillResult.needsAI || [];
|
|
973
|
+
if (needsAI.length > 0) {
|
|
974
|
+
reportFillStatus('FILLING', { message: `Getting AI answers for ${needsAI.length} custom question(s)...` });
|
|
975
|
+
const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([label, d]) => ({ label, value: d.value }));
|
|
976
|
+
for (const f of needsAI) {
|
|
977
|
+
try {
|
|
978
|
+
const ans = await fetchFieldAnswer(apiUrl, token, {
|
|
979
|
+
job_id: aep.job?.id || job_id,
|
|
980
|
+
field_label: f.label,
|
|
981
|
+
field_type: f.inputType === 'textarea' || f.tag === 'textarea' ? 'textarea' : f.inputType || 'text',
|
|
982
|
+
previously_answered: prevAnswered,
|
|
983
|
+
});
|
|
984
|
+
if (ans?.value) {
|
|
985
|
+
aep.field_answers = aep.field_answers || [];
|
|
986
|
+
aep.field_answers.push({ field_id: f.label, label: f.label, value: ans.value, confidence: ans.confidence || 0.75, source: 'on-demand' });
|
|
987
|
+
prevAnswered.push({ label: f.label, value: ans.value });
|
|
988
|
+
}
|
|
989
|
+
} catch (e) { log(`AI answer failed for "${f.label}": ${e.message}`); }
|
|
990
|
+
}
|
|
991
|
+
const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
992
|
+
fillResult.filled += retry.filled;
|
|
993
|
+
fillResult.skipped = retry.skipped;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Vision precision fill for anything DOM missed
|
|
997
|
+
if (!useVisionForThis && anthropicKey && (fillResult.skipped > 2 || fillResult.failed > 0)) {
|
|
998
|
+
reportFillStatus('FILLING', { message: 'Running vision precision fill for missed fields...' });
|
|
999
|
+
const precision = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
|
|
1000
|
+
fillResult.filled += precision.filled || 0;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (tempResumeFile) {
|
|
1004
|
+
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
1005
|
+
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let cumulativeFilled = fillResult.filled || 0;
|
|
1009
|
+
reportFillStatus('FILLING', { fieldsFilled: cumulativeFilled, message: `Filled ${cumulativeFilled} fields. Checking for more pages...` });
|
|
1010
|
+
|
|
1011
|
+
// PAGINATION — same logic as runJob
|
|
1012
|
+
let paginationAttempts = 0;
|
|
1013
|
+
while (paginationAttempts < 10) {
|
|
1014
|
+
await handleCaptchaIfPresent(page, config.captchaApiKey || null, dummyQueueId, dummyReportStatus, config);
|
|
1015
|
+
|
|
1016
|
+
const submitBtn = await findSubmitButton(page);
|
|
1017
|
+
if (submitBtn) break;
|
|
1018
|
+
|
|
1019
|
+
const nextBtn = await findNextButton(page);
|
|
1020
|
+
if (!nextBtn) {
|
|
1021
|
+
await waitForStableDOM(page, 3000);
|
|
1022
|
+
if (await findSubmitButton(page)) break;
|
|
1023
|
+
const confUrl = /thank|confirm|success|applied|submitted/i.test(page.url());
|
|
1024
|
+
if (confUrl) {
|
|
1025
|
+
reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted.' });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
reportFillStatus('FILLING', { message: `Moving to next page (${paginationAttempts + 1})...` });
|
|
1032
|
+
const fieldsBefore = await snapshotFieldLabels(page);
|
|
1033
|
+
await nextBtn.click();
|
|
1034
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
1035
|
+
await waitForStableDOM(page, 3000);
|
|
1036
|
+
|
|
1037
|
+
const fieldsAfter = await snapshotFieldLabels(page);
|
|
1038
|
+
const newFields = [...fieldsAfter].filter(l => l && !fieldsBefore.has(l));
|
|
1039
|
+
if (newFields.length > 0) {
|
|
1040
|
+
const prevAnswered = Array.from(ctx.answeredFields.entries()).map(([l, d]) => ({ label: l, value: d.value }));
|
|
1041
|
+
for (const condLabel of newFields) {
|
|
1042
|
+
const existing = (aep.field_answers || []).find(fa => fa.label?.toLowerCase().trim() === condLabel.toLowerCase().trim());
|
|
1043
|
+
if (existing) continue;
|
|
1044
|
+
try {
|
|
1045
|
+
const condAns = await fetchFieldAnswer(apiUrl, token, { job_id: aep.job?.id || job_id, field_label: condLabel, field_type: 'input', previously_answered: prevAnswered });
|
|
1046
|
+
if (condAns?.value) {
|
|
1047
|
+
aep.field_answers = aep.field_answers || [];
|
|
1048
|
+
aep.field_answers.push({ field_id: condLabel, label: condLabel, value: condAns.value, confidence: condAns.confidence || 0.75, source: 'conditional' });
|
|
1049
|
+
}
|
|
1050
|
+
} catch {}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
ctx.currentPageIndex++;
|
|
1055
|
+
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
1056
|
+
cumulativeFilled += pageResult.filled || 0;
|
|
1057
|
+
|
|
1058
|
+
if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
|
|
1059
|
+
const pr = await visionFillSkipped(page, aep, anthropicKey, ctx.answeredFields);
|
|
1060
|
+
cumulativeFilled += pr.filled || 0;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
reportFillStatus('FILLING', { fieldsFilled: cumulativeFilled, message: `Filled ${cumulativeFilled} fields total...` });
|
|
1064
|
+
paginationAttempts++;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// REVIEWING — take screenshot and send back to extension overlay
|
|
1068
|
+
reportFillStatus('REVIEWING', { fieldsFilled: cumulativeFilled, message: 'Review your application and click Submit.' });
|
|
1069
|
+
log('Reached review stage');
|
|
1070
|
+
|
|
1071
|
+
const reviewShot = await page.screenshot({ type: 'jpeg', quality: 65 }).catch(() => null);
|
|
1072
|
+
if (reviewShot) {
|
|
1073
|
+
// Send as base64 so extension can display it inline — no R2 needed for extension flow
|
|
1074
|
+
const b64 = reviewShot.toString('base64');
|
|
1075
|
+
reportFillStatus('REVIEWING', {
|
|
1076
|
+
fieldsFilled: cumulativeFilled,
|
|
1077
|
+
message: 'Review your application and click Submit in the HALO panel.',
|
|
1078
|
+
reviewScreenshot: `data:image/jpeg;base64,${b64}`,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Wait for user to confirm from extension overlay
|
|
1083
|
+
log('Waiting for user confirm from extension...');
|
|
1084
|
+
await waitForConfirm();
|
|
1085
|
+
|
|
1086
|
+
// SUBMITTING
|
|
1087
|
+
reportFillStatus('SUBMITTING', { fieldsFilled: cumulativeFilled, message: 'Submitting...' });
|
|
1088
|
+
log('Submit confirmed — clicking submit button');
|
|
1089
|
+
|
|
1090
|
+
let submitBtn = await findSubmitButton(page);
|
|
1091
|
+
if (!submitBtn && anthropicKey) {
|
|
1092
|
+
const vr = await visionNavigateAndSubmit(page, aep, anthropicKey, { autoSubmit: true, alreadyFilled: ctx.answeredFields });
|
|
1093
|
+
if (vr.submitted) {
|
|
1094
|
+
reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted!' });
|
|
1095
|
+
log('Done (via vision submit)');
|
|
1096
|
+
if (tempResumeFile) try { require('fs').unlinkSync(tempResumeFile); } catch {}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
submitBtn = await findSubmitButton(page);
|
|
1100
|
+
}
|
|
1101
|
+
if (!submitBtn) throw new Error('Could not find submit button');
|
|
1102
|
+
|
|
1103
|
+
await submitBtn.click();
|
|
1104
|
+
try { await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 15000 }); } catch {}
|
|
1105
|
+
|
|
1106
|
+
reportFillStatus('DONE', { fieldsFilled: cumulativeFilled, message: 'Application submitted!' });
|
|
1107
|
+
log('Done');
|
|
1108
|
+
|
|
1109
|
+
// Learning loop session post (non-critical)
|
|
1110
|
+
await postFillSession({ apiUrl, token }, {
|
|
1111
|
+
job_id: aep.job?.id || job_id || 'ext',
|
|
1112
|
+
ats_type: ats_type || 'unknown',
|
|
1113
|
+
pages_visited: ctx.currentPageIndex + 1,
|
|
1114
|
+
fields_filled: cumulativeFilled,
|
|
1115
|
+
skipped_fields: ctx.skippedFields,
|
|
1116
|
+
conditional_fields: ctx.conditionalTriggers,
|
|
1117
|
+
}).catch(() => {});
|
|
1118
|
+
|
|
1119
|
+
if (tempResumeFile) try { require('fs').unlinkSync(tempResumeFile); } catch {}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
module.exports = { runJob, runExtensionFill };
|