@reconcrap/boss-recommend-mcp 2.0.4 → 2.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/chat-mcp.js CHANGED
@@ -494,7 +494,8 @@ async function waitForHealthyChat(client, config, {
494
494
  domain: "chat",
495
495
  roots: roots.roots,
496
496
  selectorProbes: config.selectorProbes,
497
- accessibilityProbes: config.accessibilityProbes
497
+ accessibilityProbes: config.accessibilityProbes,
498
+ viewportProbes: config.viewportProbes
498
499
  });
499
500
  if (lastCheck.status === HEALTH_STATUS.HEALTHY) return lastCheck;
500
501
  await sleep(intervalMs);
@@ -10,6 +10,7 @@ export const BOSS_LOGIN_URL = "https://www.zhipin.com/web/user/?ka=bticket";
10
10
 
11
11
  export const ALLOWED_CDP_DOMAINS = new Set([
12
12
  "Accessibility",
13
+ "Browser",
13
14
  "DOM",
14
15
  "Input",
15
16
  "Network",
@@ -5,6 +5,26 @@ import {
5
5
  querySelectorAll,
6
6
  sleep
7
7
  } from "../browser/index.js";
8
+ import {
9
+ compactViewportHealthResult,
10
+ ensureHealthyViewport
11
+ } from "./viewport.js";
12
+
13
+ export {
14
+ buildViewportHealthDiagnostics,
15
+ compactViewportHealthResult,
16
+ compactViewportState,
17
+ createViewportRunGuard,
18
+ ensureHealthyViewport,
19
+ getCurrentWindowInfo,
20
+ isListViewportCollapsed,
21
+ readViewportState,
22
+ setWindowStateIfPossible,
23
+ toggleWindowStateForViewportRecovery,
24
+ VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH,
25
+ VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO,
26
+ VIEWPORT_COLLAPSE_RATIO_THRESHOLD
27
+ } from "./viewport.js";
8
28
 
9
29
  export const PROBE_STATUS = Object.freeze({
10
30
  PASS: "pass",
@@ -245,6 +265,26 @@ export function createNetworkProbe({
245
265
  };
246
266
  }
247
267
 
268
+ export function createViewportCollapseProbe({
269
+ id = "viewport_collapse",
270
+ root = "frame",
271
+ frameOwnerRoot = "frameOwner",
272
+ required = true,
273
+ repair = true,
274
+ description = ""
275
+ } = {}) {
276
+ if (!id) throw new Error("Viewport collapse probe requires an id");
277
+ return {
278
+ type: "viewport",
279
+ id,
280
+ root,
281
+ frameOwnerRoot,
282
+ required: Boolean(required),
283
+ repair: Boolean(repair),
284
+ description
285
+ };
286
+ }
287
+
248
288
  export async function runSelectorProbe(client, roots, probe) {
249
289
  const nodeId = rootNodeId(roots, probe.root);
250
290
  if (!nodeId) {
@@ -338,6 +378,52 @@ export function runNetworkProbe(networkEvents = [], probe) {
338
378
  };
339
379
  }
340
380
 
381
+ export async function runViewportCollapseProbe(client, roots, probe) {
382
+ const nodeId = rootNodeId(roots, probe.root);
383
+ if (!nodeId) {
384
+ return {
385
+ ...probe,
386
+ ok: !probe.required,
387
+ status: probe.required ? PROBE_STATUS.BLOCKED : PROBE_STATUS.OPTIONAL_ABSENT,
388
+ count: 0,
389
+ collapsed: false,
390
+ recovered: false,
391
+ error: `Root not found: ${probe.root}`
392
+ };
393
+ }
394
+
395
+ try {
396
+ const health = await ensureHealthyViewport(client, {
397
+ roots,
398
+ root: probe.root,
399
+ frameOwnerRoot: probe.frameOwnerRoot,
400
+ reason: probe.id,
401
+ repair: probe.repair
402
+ });
403
+ const ok = Boolean(health.ok);
404
+ return {
405
+ ...probe,
406
+ ok: probe.required ? ok : true,
407
+ status: ok ? PROBE_STATUS.PASS : probe.required ? PROBE_STATUS.FAIL : PROBE_STATUS.OPTIONAL_ABSENT,
408
+ count: ok ? 1 : 0,
409
+ collapsed: Boolean(health.collapsed),
410
+ recovered: Boolean(health.recovered),
411
+ viewport_health: compactViewportHealthResult(health),
412
+ error: health.error || null
413
+ };
414
+ } catch (error) {
415
+ return {
416
+ ...probe,
417
+ ok: !probe.required,
418
+ status: probe.required ? PROBE_STATUS.ERROR : PROBE_STATUS.OPTIONAL_ABSENT,
419
+ count: 0,
420
+ collapsed: false,
421
+ recovered: false,
422
+ error: error?.message || String(error)
423
+ };
424
+ }
425
+ }
426
+
341
427
  export function summarizeProbeResults(probes = []) {
342
428
  const required = probes.filter((probe) => probe.required);
343
429
  const blocked = required.filter((probe) => probe.status === PROBE_STATUS.BLOCKED);
@@ -370,6 +456,7 @@ export function buildDriftReport(probes = []) {
370
456
  expected_min_count: probe.minCount,
371
457
  observed_count: probe.count || 0,
372
458
  selectors: probe.selectors || [],
459
+ viewport_health: probe.viewport_health || undefined,
373
460
  error: probe.error || null
374
461
  }));
375
462
  }
@@ -380,6 +467,7 @@ export async function runSelfHealCheck({
380
467
  roots = {},
381
468
  selectorProbes = [],
382
469
  accessibilityProbes = [],
470
+ viewportProbes = [],
383
471
  networkProbes = [],
384
472
  networkEvents = []
385
473
  } = {}) {
@@ -393,8 +481,13 @@ export async function runSelfHealCheck({
393
481
  accessibilityResults.push(await runAccessibilityProbe(client, probe));
394
482
  }
395
483
 
484
+ const viewportResults = [];
485
+ for (const probe of viewportProbes) {
486
+ viewportResults.push(await runViewportCollapseProbe(client, roots, probe));
487
+ }
488
+
396
489
  const networkResults = networkProbes.map((probe) => runNetworkProbe(networkEvents, probe));
397
- const probes = [...selectorResults, ...accessibilityResults, ...networkResults];
490
+ const probes = [...selectorResults, ...accessibilityResults, ...viewportResults, ...networkResults];
398
491
  const summary = summarizeProbeResults(probes);
399
492
 
400
493
  return {
@@ -507,6 +600,16 @@ export function buildRecommendSelfHealConfig(rules = {}) {
507
600
  description: "Candidate detail popup may mount inside the recommend frame"
508
601
  })
509
602
  ],
603
+ viewportProbes: [
604
+ createViewportCollapseProbe({
605
+ id: "recommend_viewport_collapse",
606
+ root: "frame",
607
+ frameOwnerRoot: "frameOwner",
608
+ required: true,
609
+ repair: true,
610
+ description: "Recommend frame/list viewport has not collapsed relative to the Chrome window"
611
+ })
612
+ ],
510
613
  accessibilityProbes: [
511
614
  createAccessibilityProbe({
512
615
  id: "accessibility_tree",
@@ -610,6 +713,16 @@ export function buildRecruitSelfHealConfig(rules = {}) {
610
713
  description: "Candidate detail popup may mount inside the search frame"
611
714
  })
612
715
  ],
716
+ viewportProbes: [
717
+ createViewportCollapseProbe({
718
+ id: "recruit_viewport_collapse",
719
+ root: "frame",
720
+ frameOwnerRoot: "frameOwner",
721
+ required: true,
722
+ repair: true,
723
+ description: "Search frame/list viewport has not collapsed relative to the Chrome window"
724
+ })
725
+ ],
613
726
  accessibilityProbes: [
614
727
  createAccessibilityProbe({
615
728
  id: "accessibility_tree",
@@ -711,6 +824,16 @@ export function buildChatSelfHealConfig(rules = {}) {
711
824
  description: "Resume iframe appears after the online resume is opened"
712
825
  })
713
826
  ],
827
+ viewportProbes: [
828
+ createViewportCollapseProbe({
829
+ id: "chat_viewport_collapse",
830
+ root: "top",
831
+ frameOwnerRoot: "top",
832
+ required: true,
833
+ repair: true,
834
+ description: "Chat list viewport has not collapsed relative to the Chrome window"
835
+ })
836
+ ],
714
837
  accessibilityProbes: [
715
838
  createAccessibilityProbe({
716
839
  id: "accessibility_tree",
@@ -756,7 +879,8 @@ export async function resolveRecommendSelfHealRoots(client, config = buildRecomm
756
879
  return {
757
880
  roots: {
758
881
  top: topRoot.nodeId,
759
- frame: iframe.documentNodeId
882
+ frame: iframe.documentNodeId,
883
+ frameOwner: iframe.nodeId
760
884
  },
761
885
  topRoot,
762
886
  iframe
@@ -779,7 +903,8 @@ export async function resolveRecruitSelfHealRoots(client, config = buildRecruitS
779
903
  return {
780
904
  roots: {
781
905
  top: topRoot.nodeId,
782
- frame: iframe.documentNodeId
906
+ frame: iframe.documentNodeId,
907
+ frameOwner: iframe.nodeId
783
908
  },
784
909
  topRoot,
785
910
  iframe
@@ -0,0 +1,564 @@
1
+ import {
2
+ getNodeBox,
3
+ querySelector,
4
+ sleep
5
+ } from "../browser/index.js";
6
+
7
+ export const VIEWPORT_COLLAPSE_RATIO_THRESHOLD = 0.6;
8
+ export const VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH = 1000;
9
+ export const VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO = 0.85;
10
+
11
+ const ABSOLUTE_COLLAPSE_LIMITS = Object.freeze({
12
+ clientHeight: 260,
13
+ clientWidth: 280,
14
+ frameHeight: 320,
15
+ frameWidth: 460,
16
+ viewportHeight: 260,
17
+ viewportWidth: 360
18
+ });
19
+
20
+ function normalizeText(value) {
21
+ return String(value ?? "").replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function getPositiveNumber(...values) {
25
+ for (const value of values) {
26
+ const number = Number(value);
27
+ if (Number.isFinite(number) && number > 0) return number;
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ function rootNodeId(roots = {}, name) {
33
+ const root = roots[name];
34
+ if (typeof root === "number") return root;
35
+ if (root?.nodeId) return root.nodeId;
36
+ if (root?.documentNodeId) return root.documentNodeId;
37
+ return 0;
38
+ }
39
+
40
+ function compactRect(rect = {}) {
41
+ return {
42
+ width: getPositiveNumber(rect.width),
43
+ height: getPositiveNumber(rect.height)
44
+ };
45
+ }
46
+
47
+ function pickViewportSize(layoutMetrics = {}, axis = "width") {
48
+ const clientKey = axis === "width" ? "clientWidth" : "clientHeight";
49
+ return getPositiveNumber(
50
+ layoutMetrics?.cssVisualViewport?.[clientKey],
51
+ layoutMetrics?.cssLayoutViewport?.[clientKey],
52
+ layoutMetrics?.visualViewport?.[clientKey],
53
+ layoutMetrics?.layoutViewport?.[clientKey]
54
+ );
55
+ }
56
+
57
+ async function getLayoutMetrics(client) {
58
+ if (typeof client?.Page?.getLayoutMetrics !== "function") return null;
59
+ try {
60
+ return await client.Page.getLayoutMetrics();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export async function getCurrentWindowInfo(client) {
67
+ if (typeof client?.Browser?.getWindowForTarget !== "function") {
68
+ return {
69
+ ok: false,
70
+ unsupported: true,
71
+ error: "Browser.getWindowForTarget is not available"
72
+ };
73
+ }
74
+
75
+ try {
76
+ const targetWindow = await client.Browser.getWindowForTarget({});
77
+ let bounds = targetWindow?.bounds || null;
78
+ if (
79
+ targetWindow?.windowId
80
+ && typeof client?.Browser?.getWindowBounds === "function"
81
+ ) {
82
+ const currentBounds = await client.Browser.getWindowBounds({
83
+ windowId: targetWindow.windowId
84
+ });
85
+ bounds = currentBounds?.bounds || bounds;
86
+ }
87
+ return {
88
+ ok: true,
89
+ windowId: targetWindow?.windowId || null,
90
+ bounds
91
+ };
92
+ } catch (error) {
93
+ return {
94
+ ok: false,
95
+ error: error?.message || String(error)
96
+ };
97
+ }
98
+ }
99
+
100
+ async function readBox(client, nodeId) {
101
+ if (!nodeId) return null;
102
+ try {
103
+ return await getNodeBox(client, nodeId);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ async function readBestContentBox(client, rootNodeIdValue) {
110
+ const directBox = await readBox(client, rootNodeIdValue);
111
+ if (directBox?.rect?.width && directBox?.rect?.height) return directBox;
112
+
113
+ for (const selector of ["body", "html"]) {
114
+ const nodeId = await querySelector(client, rootNodeIdValue, selector).catch(() => 0);
115
+ const box = await readBox(client, nodeId);
116
+ if (box?.rect?.width && box?.rect?.height) return box;
117
+ }
118
+ return directBox;
119
+ }
120
+
121
+ export function buildViewportHealthDiagnostics(state, windowInfo = null, layoutMetrics = null) {
122
+ const topViewport = state?.topViewport || {};
123
+ const bounds = windowInfo?.bounds || null;
124
+ const windowState = normalizeText(bounds?.windowState || "").toLowerCase() || null;
125
+ const windowWidth = getPositiveNumber(bounds?.width);
126
+ const screenAvailWidth = getPositiveNumber(topViewport.screenAvailWidth);
127
+ const topOuterWidth = getPositiveNumber(topViewport.outerWidth);
128
+ const actualWidth = getPositiveNumber(
129
+ layoutMetrics?.cssVisualViewport?.clientWidth,
130
+ layoutMetrics?.cssLayoutViewport?.clientWidth,
131
+ topViewport.visualWidth,
132
+ topViewport.innerWidth,
133
+ state?.viewport?.width,
134
+ state?.clientWidth,
135
+ state?.frameRect?.width
136
+ );
137
+ const actualHeight = getPositiveNumber(
138
+ layoutMetrics?.cssVisualViewport?.clientHeight,
139
+ layoutMetrics?.cssLayoutViewport?.clientHeight,
140
+ topViewport.visualHeight,
141
+ topViewport.innerHeight,
142
+ state?.viewport?.height,
143
+ state?.clientHeight,
144
+ state?.frameRect?.height
145
+ );
146
+ const hasScreenWidth = screenAvailWidth > 0;
147
+ const nearFullscreen = Boolean(
148
+ windowState === "maximized"
149
+ || (
150
+ windowWidth > 0
151
+ && hasScreenWidth
152
+ && windowWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
153
+ )
154
+ || (
155
+ topOuterWidth > 0
156
+ && hasScreenWidth
157
+ && topOuterWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
158
+ )
159
+ );
160
+ const fallbackExpectedWidth = getPositiveNumber(screenAvailWidth, windowWidth, topOuterWidth);
161
+ let expectedWidth = 0;
162
+ if (windowWidth > 0) {
163
+ expectedWidth = hasScreenWidth && windowWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
164
+ ? Math.min(windowWidth, screenAvailWidth)
165
+ : windowWidth;
166
+ } else if (topOuterWidth > 0) {
167
+ expectedWidth = hasScreenWidth && topOuterWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
168
+ ? Math.min(topOuterWidth, screenAvailWidth)
169
+ : topOuterWidth;
170
+ } else {
171
+ expectedWidth = fallbackExpectedWidth;
172
+ }
173
+ const widthRatio = actualWidth > 0 && expectedWidth > 0
174
+ ? actualWidth / expectedWidth
175
+ : null;
176
+ const relativeCollapsed = Boolean(
177
+ nearFullscreen
178
+ && expectedWidth >= VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH
179
+ && actualWidth > 0
180
+ && widthRatio !== null
181
+ && widthRatio <= VIEWPORT_COLLAPSE_RATIO_THRESHOLD
182
+ );
183
+
184
+ return {
185
+ threshold: VIEWPORT_COLLAPSE_RATIO_THRESHOLD,
186
+ minExpectedWidth: VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH,
187
+ nearFullscreen,
188
+ windowState,
189
+ windowWidth,
190
+ screenAvailWidth,
191
+ topOuterWidth,
192
+ actualWidth,
193
+ actualHeight,
194
+ expectedWidth,
195
+ widthRatio,
196
+ relativeCollapsed
197
+ };
198
+ }
199
+
200
+ export function isListViewportCollapsed(state) {
201
+ if (!state?.ok) return false;
202
+ if (state.viewportDiagnostics?.relativeCollapsed === true) return true;
203
+ const clientHeight = Number(state.clientHeight || 0);
204
+ const clientWidth = Number(state.clientWidth || 0);
205
+ const frameWidth = Number(state.frameRect?.width || 0);
206
+ const frameHeight = Number(state.frameRect?.height || 0);
207
+ const viewportWidth = Number(state.viewport?.width || 0);
208
+ const viewportHeight = Number(state.viewport?.height || 0);
209
+
210
+ return (
211
+ (clientHeight > 0 && clientHeight < ABSOLUTE_COLLAPSE_LIMITS.clientHeight)
212
+ || (clientWidth > 0 && clientWidth < ABSOLUTE_COLLAPSE_LIMITS.clientWidth)
213
+ || (frameHeight > 0 && frameHeight < ABSOLUTE_COLLAPSE_LIMITS.frameHeight)
214
+ || (frameWidth > 0 && frameWidth < ABSOLUTE_COLLAPSE_LIMITS.frameWidth)
215
+ || (viewportHeight > 0 && viewportHeight < ABSOLUTE_COLLAPSE_LIMITS.viewportHeight)
216
+ || (viewportWidth > 0 && viewportWidth < ABSOLUTE_COLLAPSE_LIMITS.viewportWidth)
217
+ );
218
+ }
219
+
220
+ export async function readViewportState(client, {
221
+ roots = {},
222
+ root = "frame",
223
+ frameOwnerRoot = "frameOwner"
224
+ } = {}) {
225
+ const targetRootNodeId = rootNodeId(roots, root);
226
+ if (!targetRootNodeId) {
227
+ return {
228
+ ok: false,
229
+ root,
230
+ error: `Root not found: ${root}`
231
+ };
232
+ }
233
+
234
+ const layoutMetrics = await getLayoutMetrics(client);
235
+ const windowInfo = await getCurrentWindowInfo(client);
236
+ const contentBox = await readBestContentBox(client, targetRootNodeId);
237
+ const ownerNodeId = rootNodeId(roots, frameOwnerRoot);
238
+ const ownerBox = ownerNodeId ? await readBox(client, ownerNodeId) : null;
239
+ const frameRect = compactRect(ownerBox?.rect || contentBox?.rect || {});
240
+ const clientWidth = getPositiveNumber(
241
+ contentBox?.rect?.width,
242
+ frameRect.width,
243
+ pickViewportSize(layoutMetrics, "width")
244
+ );
245
+ const clientHeight = getPositiveNumber(
246
+ contentBox?.rect?.height,
247
+ frameRect.height,
248
+ pickViewportSize(layoutMetrics, "height")
249
+ );
250
+ const viewportWidth = pickViewportSize(layoutMetrics, "width") || clientWidth;
251
+ const viewportHeight = pickViewportSize(layoutMetrics, "height") || clientHeight;
252
+ const bounds = windowInfo?.bounds || {};
253
+ const topViewport = {
254
+ innerWidth: viewportWidth,
255
+ innerHeight: viewportHeight,
256
+ outerWidth: getPositiveNumber(bounds.width, viewportWidth),
257
+ outerHeight: getPositiveNumber(bounds.height, viewportHeight),
258
+ visualWidth: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientWidth, viewportWidth),
259
+ visualHeight: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientHeight, viewportHeight),
260
+ screenAvailWidth: getPositiveNumber(bounds.width),
261
+ screenAvailHeight: getPositiveNumber(bounds.height),
262
+ devicePixelRatio: getPositiveNumber(layoutMetrics?.cssVisualViewport?.scale, 1)
263
+ };
264
+ const state = {
265
+ ok: true,
266
+ root,
267
+ rootNodeId: targetRootNodeId,
268
+ frameOwnerRoot,
269
+ frameOwnerNodeId: ownerNodeId || null,
270
+ clientWidth,
271
+ clientHeight,
272
+ frameRect,
273
+ viewport: {
274
+ width: viewportWidth,
275
+ height: viewportHeight
276
+ },
277
+ topViewport,
278
+ windowInfo
279
+ };
280
+ state.viewportDiagnostics = buildViewportHealthDiagnostics(state, windowInfo, layoutMetrics);
281
+ state.collapsed = isListViewportCollapsed(state);
282
+ return state;
283
+ }
284
+
285
+ export async function setWindowStateIfPossible(client, windowState, reason = "viewport_recovery") {
286
+ const windowInfo = await getCurrentWindowInfo(client);
287
+ if (!windowInfo.ok || !windowInfo.windowId || typeof client?.Browser?.setWindowBounds !== "function") {
288
+ return {
289
+ ok: false,
290
+ reason,
291
+ windowState,
292
+ error: windowInfo.error || "Browser.setWindowBounds is not available"
293
+ };
294
+ }
295
+
296
+ try {
297
+ await client.Browser.setWindowBounds({
298
+ windowId: windowInfo.windowId,
299
+ bounds: {
300
+ windowState
301
+ }
302
+ });
303
+ return {
304
+ ok: true,
305
+ reason,
306
+ windowState,
307
+ windowId: windowInfo.windowId,
308
+ before: windowInfo.bounds || null
309
+ };
310
+ } catch (error) {
311
+ return {
312
+ ok: false,
313
+ reason,
314
+ windowState,
315
+ windowId: windowInfo.windowId,
316
+ error: error?.message || String(error)
317
+ };
318
+ }
319
+ }
320
+
321
+ export async function toggleWindowStateForViewportRecovery(client, {
322
+ reason = "viewport_recovery",
323
+ settleMs = 520,
324
+ bringToFront = true
325
+ } = {}) {
326
+ const currentInfo = await getCurrentWindowInfo(client);
327
+ const currentState = normalizeText(currentInfo?.bounds?.windowState || "").toLowerCase();
328
+ const sequence = currentState === "normal"
329
+ ? ["maximized", "normal"]
330
+ : ["normal", "maximized"];
331
+ const attempts = [];
332
+
333
+ for (const windowState of sequence) {
334
+ const attempt = await setWindowStateIfPossible(client, windowState, reason);
335
+ attempts.push(attempt);
336
+ if (attempt.ok && settleMs > 0) await sleep(settleMs);
337
+ }
338
+
339
+ if (bringToFront && typeof client?.Page?.bringToFront === "function") {
340
+ await client.Page.bringToFront();
341
+ }
342
+
343
+ return {
344
+ ok: attempts.some((attempt) => attempt.ok),
345
+ applied: attempts.some((attempt) => attempt.ok),
346
+ reason,
347
+ current_state: currentState || null,
348
+ sequence,
349
+ attempts
350
+ };
351
+ }
352
+
353
+ export function compactViewportState(state = null) {
354
+ if (!state) return null;
355
+ return {
356
+ ok: Boolean(state.ok),
357
+ root: state.root || null,
358
+ error: state.error || null,
359
+ clientWidth: state.clientWidth || 0,
360
+ clientHeight: state.clientHeight || 0,
361
+ frameRect: state.frameRect || null,
362
+ viewport: state.viewport || null,
363
+ topViewport: state.topViewport
364
+ ? {
365
+ innerWidth: state.topViewport.innerWidth || 0,
366
+ innerHeight: state.topViewport.innerHeight || 0,
367
+ outerWidth: state.topViewport.outerWidth || 0,
368
+ outerHeight: state.topViewport.outerHeight || 0,
369
+ visualWidth: state.topViewport.visualWidth || 0,
370
+ visualHeight: state.topViewport.visualHeight || 0,
371
+ screenAvailWidth: state.topViewport.screenAvailWidth || 0,
372
+ screenAvailHeight: state.topViewport.screenAvailHeight || 0,
373
+ devicePixelRatio: state.topViewport.devicePixelRatio || 0
374
+ }
375
+ : null,
376
+ viewportDiagnostics: state.viewportDiagnostics || null,
377
+ collapsed: Boolean(state.collapsed)
378
+ };
379
+ }
380
+
381
+ export function compactViewportHealthResult(result = null) {
382
+ if (!result) return null;
383
+ return {
384
+ ok: Boolean(result.ok),
385
+ collapsed: Boolean(result.collapsed),
386
+ recovered: Boolean(result.recovered),
387
+ reason: result.reason || null,
388
+ state: compactViewportState(result.state),
389
+ before: compactViewportState(result.before),
390
+ repair: result.repair
391
+ ? {
392
+ ok: Boolean(result.repair.ok),
393
+ applied: Boolean(result.repair.applied),
394
+ current_state: result.repair.current_state || null,
395
+ sequence: result.repair.sequence || [],
396
+ attempts: (result.repair.attempts || []).map((attempt) => ({
397
+ ok: Boolean(attempt.ok),
398
+ windowState: attempt.windowState,
399
+ error: attempt.error || null
400
+ }))
401
+ }
402
+ : null,
403
+ error: result.error || null
404
+ };
405
+ }
406
+
407
+ export async function ensureHealthyViewport(client, {
408
+ roots = {},
409
+ root = "frame",
410
+ frameOwnerRoot = "frameOwner",
411
+ reason = "viewport_recovery",
412
+ repair = true,
413
+ recoveryDelayMs = 900
414
+ } = {}) {
415
+ const before = await readViewportState(client, {
416
+ roots,
417
+ root,
418
+ frameOwnerRoot
419
+ });
420
+ if (!before.ok) {
421
+ return {
422
+ ok: false,
423
+ collapsed: false,
424
+ recovered: false,
425
+ reason,
426
+ state: before,
427
+ error: before.error || "viewport state could not be read"
428
+ };
429
+ }
430
+
431
+ if (!isListViewportCollapsed(before)) {
432
+ return {
433
+ ok: true,
434
+ collapsed: false,
435
+ recovered: false,
436
+ reason,
437
+ state: before
438
+ };
439
+ }
440
+
441
+ if (!repair) {
442
+ return {
443
+ ok: false,
444
+ collapsed: true,
445
+ recovered: false,
446
+ reason,
447
+ before,
448
+ state: before,
449
+ error: "viewport collapsed and repair disabled"
450
+ };
451
+ }
452
+
453
+ const repairResult = await toggleWindowStateForViewportRecovery(client, { reason });
454
+ if (recoveryDelayMs > 0) await sleep(recoveryDelayMs);
455
+ const after = await readViewportState(client, {
456
+ roots,
457
+ root,
458
+ frameOwnerRoot
459
+ });
460
+ const stillCollapsed = isListViewportCollapsed(after);
461
+ return {
462
+ ok: after.ok && !stillCollapsed,
463
+ collapsed: stillCollapsed,
464
+ recovered: after.ok && !stillCollapsed && repairResult.applied,
465
+ reason,
466
+ before,
467
+ state: after,
468
+ repair: repairResult,
469
+ error: after.ok && !stillCollapsed
470
+ ? null
471
+ : "viewport collapsed after recovery attempt"
472
+ };
473
+ }
474
+
475
+ export function createViewportRunGuard({
476
+ client,
477
+ domain = "boss",
478
+ root = "frame",
479
+ frameOwnerRoot = "frameOwner",
480
+ runControl = null,
481
+ getRoots = null,
482
+ rootNodesFromState = (rootState) => rootState?.rootNodes || rootState?.roots || rootState || {},
483
+ repair = true,
484
+ maxEvents = 10
485
+ } = {}) {
486
+ if (!client) throw new Error("createViewportRunGuard requires a guarded CDP client");
487
+ const events = [];
488
+ const stats = {
489
+ checks: 0,
490
+ recoveries: 0,
491
+ failures: 0
492
+ };
493
+
494
+ function recordEvent(phase, health) {
495
+ const compact = compactViewportHealthResult(health);
496
+ const shouldRecord = Boolean(health?.recovered || !health?.ok || health?.collapsed);
497
+ if (!shouldRecord) return compact;
498
+ const event = {
499
+ phase,
500
+ at: new Date().toISOString(),
501
+ ...compact
502
+ };
503
+ events.push(event);
504
+ if (events.length > maxEvents) events.shift();
505
+ if (runControl) {
506
+ runControl.checkpoint({
507
+ viewport_health: event,
508
+ viewport_health_events: events.slice(),
509
+ viewport_health_stats: { ...stats }
510
+ });
511
+ }
512
+ return compact;
513
+ }
514
+
515
+ async function ensure(rootState, {
516
+ phase = "run",
517
+ reason = `${domain}:${phase}`
518
+ } = {}) {
519
+ let currentRootState = rootState;
520
+ if (!currentRootState && typeof getRoots === "function") {
521
+ currentRootState = await getRoots(client);
522
+ }
523
+ const roots = rootNodesFromState(currentRootState);
524
+ stats.checks += 1;
525
+ const health = await ensureHealthyViewport(client, {
526
+ roots,
527
+ root,
528
+ frameOwnerRoot,
529
+ reason,
530
+ repair
531
+ });
532
+ if (health.recovered) stats.recoveries += 1;
533
+ if (!health.ok) stats.failures += 1;
534
+ const compact = recordEvent(phase, health);
535
+ if (!health.ok) {
536
+ const error = new Error(`${String(domain).toUpperCase()}_LIST_VIEWPORT_COLLAPSED`);
537
+ error.code = "LIST_VIEWPORT_COLLAPSED";
538
+ error.domain = domain;
539
+ error.phase = phase;
540
+ error.viewport_health = compact;
541
+ throw error;
542
+ }
543
+ if (health.recovered && typeof getRoots === "function") {
544
+ currentRootState = await getRoots(client);
545
+ }
546
+ return {
547
+ rootState: currentRootState,
548
+ health,
549
+ compact,
550
+ stats: { ...stats },
551
+ events: events.slice()
552
+ };
553
+ }
554
+
555
+ return {
556
+ ensure,
557
+ getStats() {
558
+ return { ...stats };
559
+ },
560
+ getEvents() {
561
+ return events.slice();
562
+ }
563
+ };
564
+ }
@@ -22,6 +22,7 @@ import {
22
22
  getNextInfiniteListCandidate,
23
23
  markInfiniteListCandidateProcessed
24
24
  } from "../../core/infinite-list/index.js";
25
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
25
26
  import { createRunLifecycleManager } from "../../core/run/index.js";
26
27
  import {
27
28
  callScreeningLlm,
@@ -250,9 +251,13 @@ async function setupChatRunContext(client, {
250
251
  normalizedStartFrom,
251
252
  readyTimeoutMs,
252
253
  listSettleMs,
253
- runControl
254
+ runControl,
255
+ ensureViewport = null
254
256
  } = {}) {
255
- const rootState = await getChatRoots(client);
257
+ let rootState = await getChatRoots(client);
258
+ if (ensureViewport) {
259
+ rootState = await ensureViewport(rootState, "context_roots");
260
+ }
256
261
  runControl.checkpoint({
257
262
  top_document_node_id: rootState.rootNodes.top
258
263
  });
@@ -280,6 +285,10 @@ async function setupChatRunContext(client, {
280
285
  if (normalizeText(job) && !jobSelection.selected) {
281
286
  throw new Error(`Chat job selection failed: ${jobSelection.reason || "unknown"}`);
282
287
  }
288
+ rootState = await getChatRoots(client);
289
+ if (ensureViewport) {
290
+ rootState = await ensureViewport(rootState, "context_job");
291
+ }
283
292
  runControl.checkpoint({
284
293
  chat_context_step: "job_selection",
285
294
  primary_label: primaryLabel,
@@ -294,6 +303,10 @@ async function setupChatRunContext(client, {
294
303
  if (!startFilter.ok) {
295
304
  throw new Error(`Chat start filter selection failed: ${startFilter.error || "unknown"}`);
296
305
  }
306
+ rootState = await getChatRoots(client);
307
+ if (ensureViewport) {
308
+ rootState = await ensureViewport(rootState, "context_start_filter");
309
+ }
297
310
  runControl.checkpoint({
298
311
  chat_context_step: "start_filter",
299
312
  primary_label: primaryLabel,
@@ -362,6 +375,18 @@ export async function runChatWorkflow({
362
375
  domain: "chat",
363
376
  listName: "chat-candidates"
364
377
  });
378
+ const viewportGuard = createViewportRunGuard({
379
+ client,
380
+ domain: "chat",
381
+ root: "top",
382
+ frameOwnerRoot: "top",
383
+ runControl,
384
+ getRoots: getChatRoots
385
+ });
386
+ async function ensureChatViewport(rootState, phase) {
387
+ const result = await viewportGuard.ensure(rootState, { phase });
388
+ return result.rootState || rootState;
389
+ }
365
390
  const results = [];
366
391
  let cardNodeIds = [];
367
392
  let listEndReason = "";
@@ -398,7 +423,8 @@ export async function runChatWorkflow({
398
423
  normalizedStartFrom,
399
424
  readyTimeoutMs,
400
425
  listSettleMs,
401
- runControl
426
+ runControl,
427
+ ensureViewport: ensureChatViewport
402
428
  });
403
429
  let rootState = setup.rootState;
404
430
  contextSetup = {
@@ -431,7 +457,8 @@ export async function runChatWorkflow({
431
457
  normalizedStartFrom,
432
458
  readyTimeoutMs,
433
459
  listSettleMs,
434
- runControl
460
+ runControl,
461
+ ensureViewport: ensureChatViewport
435
462
  });
436
463
  rootState = recoveredSetup.rootState;
437
464
  contextSetup = {
@@ -449,7 +476,7 @@ export async function runChatWorkflow({
449
476
  await runControl.waitIfPaused();
450
477
  runControl.throwIfCanceled();
451
478
  runControl.setPhase("chat:cards");
452
- const cardRootState = await getChatRoots(client);
479
+ const cardRootState = await ensureChatViewport(await getChatRoots(client), "cards");
453
480
  const initialCards = await waitForChatCandidateNodeIds(client, cardRootState.rootNodes.top, {
454
481
  timeoutMs: cardTimeoutMs,
455
482
  intervalMs: 500
@@ -479,7 +506,9 @@ export async function runChatWorkflow({
479
506
  request_skipped: 0,
480
507
  unique_seen: compactInfiniteListState(listState).seen_count,
481
508
  scroll_count: compactInfiniteListState(listState).scroll_count,
482
- list_end_reason: listEndReason
509
+ list_end_reason: listEndReason,
510
+ viewport_checks: viewportGuard.getStats().checks,
511
+ viewport_recoveries: viewportGuard.getStats().recoveries
483
512
  });
484
513
  runControl.setPhase("chat:done");
485
514
  return {
@@ -493,6 +522,10 @@ export async function runChatWorkflow({
493
522
  requested_start_from: normalizedStartFrom
494
523
  },
495
524
  candidate_list: compactInfiniteListState(listState),
525
+ viewport_health: {
526
+ stats: viewportGuard.getStats(),
527
+ events: viewportGuard.getEvents()
528
+ },
496
529
  list_end_reason: listEndReason,
497
530
  target_pass_count: passTarget,
498
531
  process_until_list_end: Boolean(processUntilListEnd),
@@ -524,7 +557,9 @@ export async function runChatWorkflow({
524
557
  request_satisfied: 0,
525
558
  request_skipped: 0,
526
559
  unique_seen: compactInfiniteListState(listState).seen_count,
527
- scroll_count: 0
560
+ scroll_count: 0,
561
+ viewport_checks: viewportGuard.getStats().checks,
562
+ viewport_recoveries: viewportGuard.getStats().recoveries
528
563
  });
529
564
 
530
565
  while (
@@ -537,6 +572,7 @@ export async function runChatWorkflow({
537
572
  await runControl.waitIfPaused();
538
573
  runControl.throwIfCanceled();
539
574
  runControl.setPhase("chat:candidate");
575
+ rootState = await ensureChatViewport(rootState, "candidate_loop");
540
576
  const loopTopLevelState = await getChatTopLevelState(client);
541
577
  if (!loopTopLevelState.is_chat_shell) {
542
578
  await recoverAndReapplyChatContext("candidate_loop_non_chat_shell", {
@@ -554,7 +590,8 @@ export async function runChatWorkflow({
554
590
  settleMs: listSettleMs,
555
591
  fallbackPoint: listFallbackPoint,
556
592
  findNodeIds: async () => {
557
- const currentRootState = await getChatRoots(client);
593
+ const currentRootState = await ensureChatViewport(await getChatRoots(client), "candidate_find_nodes");
594
+ rootState = currentRootState;
558
595
  const currentCards = await waitForChatCandidateNodeIds(client, currentRootState.rootNodes.top, {
559
596
  timeoutMs: Math.min(cardTimeoutMs, 8000),
560
597
  intervalMs: 500
@@ -609,6 +646,7 @@ export async function runChatWorkflow({
609
646
  await runControl.waitIfPaused();
610
647
  runControl.throwIfCanceled();
611
648
  runControl.setPhase("chat:detail");
649
+ rootState = await ensureChatViewport(rootState, "detail");
612
650
 
613
651
  detailStep = "select_candidate";
614
652
  networkRecorder.clear();
@@ -921,6 +959,8 @@ export async function runChatWorkflow({
921
959
  unique_seen: compactInfiniteListState(listState).seen_count,
922
960
  scroll_count: compactInfiniteListState(listState).scroll_count,
923
961
  list_end_reason: listEndReason || null,
962
+ viewport_checks: viewportGuard.getStats().checks,
963
+ viewport_recoveries: viewportGuard.getStats().recoveries,
924
964
  last_candidate_id: screeningCandidate.id || null,
925
965
  last_candidate_key: candidateKey,
926
966
  last_score: screening.score
@@ -950,6 +990,10 @@ export async function runChatWorkflow({
950
990
  card_count: cardNodeIds.length,
951
991
  context_setup: contextSetup,
952
992
  candidate_list: compactInfiniteListState(listState),
993
+ viewport_health: {
994
+ stats: viewportGuard.getStats(),
995
+ events: viewportGuard.getEvents()
996
+ },
953
997
  list_end_reason: listEndReason || null,
954
998
  target_pass_count: passTarget,
955
999
  process_until_list_end: Boolean(processUntilListEnd),
@@ -25,7 +25,8 @@ export async function getRecommendRoots(client, {
25
25
  ].filter(Boolean),
26
26
  rootNodes: {
27
27
  top: topRoot.nodeId,
28
- frame: iframe?.documentNodeId || 0
28
+ frame: iframe?.documentNodeId || 0,
29
+ frameOwner: iframe?.nodeId || 0
29
30
  }
30
31
  };
31
32
  }
@@ -49,7 +50,8 @@ export async function waitForRecommendRoots(client, {
49
50
  roots: [],
50
51
  rootNodes: {
51
52
  top: 0,
52
- frame: 0
53
+ frame: 0,
54
+ frameOwner: 0
53
55
  }
54
56
  };
55
57
  }
@@ -20,6 +20,7 @@ import {
20
20
  markInfiniteListCandidateProcessed,
21
21
  resetInfiniteListForRefreshRound
22
22
  } from "../../core/infinite-list/index.js";
23
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
23
24
  import { screenCandidate } from "../../core/screening/index.js";
24
25
  import {
25
26
  closeRecommendDetail,
@@ -372,6 +373,18 @@ export async function runRecommendWorkflow({
372
373
  domain: "recommend",
373
374
  listName: "recommend-candidates"
374
375
  });
376
+ const viewportGuard = createViewportRunGuard({
377
+ client,
378
+ domain: "recommend",
379
+ root: "frame",
380
+ frameOwnerRoot: "frameOwner",
381
+ runControl,
382
+ getRoots: getRecommendRoots
383
+ });
384
+ async function ensureRecommendViewport(rootState, phase) {
385
+ const result = await viewportGuard.ensure(rootState, { phase });
386
+ return result.rootState || rootState;
387
+ }
375
388
  const results = [];
376
389
  const refreshAttempts = [];
377
390
  let refreshRounds = 0;
@@ -389,6 +402,7 @@ export async function runRecommendWorkflow({
389
402
  runControl.throwIfCanceled();
390
403
  runControl.setPhase("recommend:roots");
391
404
  let rootState = await getRecommendRoots(client);
405
+ rootState = await ensureRecommendViewport(rootState, "roots");
392
406
  runControl.checkpoint({
393
407
  iframe_selector: rootState.iframe.selector,
394
408
  iframe_document_node_id: rootState.iframe.documentNodeId
@@ -406,6 +420,7 @@ export async function runRecommendWorkflow({
406
420
  throw new Error(`Requested recommend job was not selected: ${jobSelection.reason}`);
407
421
  }
408
422
  rootState = await getRecommendRoots(client);
423
+ rootState = await ensureRecommendViewport(rootState, "job");
409
424
  runControl.checkpoint({
410
425
  job_selection: compactJobSelection(jobSelection)
411
426
  });
@@ -424,6 +439,7 @@ export async function runRecommendWorkflow({
424
439
  throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
425
440
  }
426
441
  rootState = await getRecommendRoots(client);
442
+ rootState = await ensureRecommendViewport(rootState, "page_scope");
427
443
  runControl.checkpoint({
428
444
  page_scope: compactPageScopeSelection(pageScopeSelection)
429
445
  });
@@ -440,6 +456,8 @@ export async function runRecommendWorkflow({
440
456
  if (!filterResult.confirmed) {
441
457
  throw new Error("Recommend run filter selection was not confirmed");
442
458
  }
459
+ rootState = await getRecommendRoots(client);
460
+ rootState = await ensureRecommendViewport(rootState, "filter");
443
461
  runControl.checkpoint({
444
462
  filter: compactFilterResult(filterResult)
445
463
  });
@@ -448,6 +466,7 @@ export async function runRecommendWorkflow({
448
466
  await runControl.waitIfPaused();
449
467
  runControl.throwIfCanceled();
450
468
  runControl.setPhase("recommend:cards");
469
+ rootState = await ensureRecommendViewport(rootState, "cards");
451
470
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
452
471
  timeoutMs: cardTimeoutMs,
453
472
  intervalMs: 300
@@ -468,13 +487,16 @@ export async function runRecommendWorkflow({
468
487
  unique_seen: compactInfiniteListState(listState).seen_count,
469
488
  scroll_count: 0,
470
489
  refresh_rounds: 0,
471
- refresh_attempts: 0
490
+ refresh_attempts: 0,
491
+ viewport_checks: viewportGuard.getStats().checks,
492
+ viewport_recoveries: viewportGuard.getStats().recoveries
472
493
  });
473
494
 
474
495
  while (results.length < limit) {
475
496
  await runControl.waitIfPaused();
476
497
  runControl.throwIfCanceled();
477
498
  runControl.setPhase("recommend:candidate");
499
+ rootState = await ensureRecommendViewport(rootState, "candidate_loop");
478
500
 
479
501
  const nextCandidateResult = await getNextInfiniteListCandidate({
480
502
  client,
@@ -485,7 +507,9 @@ export async function runRecommendWorkflow({
485
507
  settleMs: listSettleMs,
486
508
  fallbackPoint: listFallbackPoint,
487
509
  findNodeIds: async () => {
488
- const currentRootState = await getRecommendRoots(client);
510
+ let currentRootState = await getRecommendRoots(client);
511
+ currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
512
+ rootState = currentRootState;
489
513
  const currentCardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
490
514
  timeoutMs: Math.min(cardTimeoutMs, 5000),
491
515
  intervalMs: 300
@@ -544,10 +568,13 @@ export async function runRecommendWorkflow({
544
568
  refresh_attempts: refreshAttempts.length,
545
569
  refresh_method: refreshResult.method || null,
546
570
  refresh_forced_recent_not_view: true,
547
- list_end_reason: listEndReason
571
+ list_end_reason: listEndReason,
572
+ viewport_checks: viewportGuard.getStats().checks,
573
+ viewport_recoveries: viewportGuard.getStats().recoveries
548
574
  });
549
575
  if (refreshResult.ok) {
550
576
  rootState = refreshResult.root_state || await getRecommendRoots(client);
577
+ rootState = await ensureRecommendViewport(rootState, "refresh_after");
551
578
  cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
552
579
  timeoutMs: cardTimeoutMs,
553
580
  intervalMs: 300
@@ -579,6 +606,7 @@ export async function runRecommendWorkflow({
579
606
  await runControl.waitIfPaused();
580
607
  runControl.throwIfCanceled();
581
608
  runControl.setPhase("recommend:detail");
609
+ rootState = await ensureRecommendViewport(rootState, "detail");
582
610
  networkRecorder.clear();
583
611
  const openedDetail = await openRecommendCardDetail(client, cardNodeId);
584
612
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
@@ -719,6 +747,8 @@ export async function runRecommendWorkflow({
719
747
  refresh_rounds: refreshRounds,
720
748
  refresh_attempts: refreshAttempts.length,
721
749
  list_end_reason: listEndReason || null,
750
+ viewport_checks: viewportGuard.getStats().checks,
751
+ viewport_recoveries: viewportGuard.getStats().recoveries,
722
752
  last_candidate_id: screeningCandidate.id || null,
723
753
  last_candidate_key: candidateKey,
724
754
  last_score: screening.score
@@ -756,6 +786,10 @@ export async function runRecommendWorkflow({
756
786
  filter: compactFilterResult(filterResult),
757
787
  card_count: cardNodeIds.length,
758
788
  candidate_list: compactInfiniteListState(listState),
789
+ viewport_health: {
790
+ stats: viewportGuard.getStats(),
791
+ events: viewportGuard.getEvents()
792
+ },
759
793
  list_end_reason: listEndReason || null,
760
794
  refresh_rounds: refreshRounds,
761
795
  refresh_attempts: refreshAttempts,
@@ -25,7 +25,8 @@ export async function getRecruitRoots(client, {
25
25
  ].filter(Boolean),
26
26
  rootNodes: {
27
27
  top: topRoot.nodeId,
28
- frame: iframe?.documentNodeId || 0
28
+ frame: iframe?.documentNodeId || 0,
29
+ frameOwner: iframe?.nodeId || 0
29
30
  }
30
31
  };
31
32
  }
@@ -18,6 +18,7 @@ import {
18
18
  markInfiniteListCandidateProcessed,
19
19
  resetInfiniteListForRefreshRound
20
20
  } from "../../core/infinite-list/index.js";
21
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
21
22
  import { screenCandidate } from "../../core/screening/index.js";
22
23
  import {
23
24
  closeRecruitDetail,
@@ -140,6 +141,18 @@ export async function runRecruitWorkflow({
140
141
  domain: "recruit",
141
142
  listName: "search-results"
142
143
  });
144
+ const viewportGuard = createViewportRunGuard({
145
+ client,
146
+ domain: "recruit",
147
+ root: "frame",
148
+ frameOwnerRoot: "frameOwner",
149
+ runControl,
150
+ getRoots: getRecruitRoots
151
+ });
152
+ async function ensureRecruitViewport(rootState, phase) {
153
+ const result = await viewportGuard.ensure(rootState, { phase });
154
+ return result.rootState || rootState;
155
+ }
143
156
  const results = [];
144
157
  const refreshAttempts = [];
145
158
  let refreshRounds = 0;
@@ -153,6 +166,7 @@ export async function runRecruitWorkflow({
153
166
  runControl.throwIfCanceled();
154
167
  runControl.setPhase("recruit:roots");
155
168
  let rootState = await getRecruitRoots(client);
169
+ rootState = await ensureRecruitViewport(rootState, "roots");
156
170
  runControl.checkpoint({
157
171
  iframe_selector: rootState.iframe.selector,
158
172
  iframe_document_node_id: rootState.iframe.documentNodeId,
@@ -186,11 +200,13 @@ export async function runRecruitWorkflow({
186
200
  }
187
201
  });
188
202
  rootState = await getRecruitRoots(client);
203
+ rootState = await ensureRecruitViewport(rootState, "search");
189
204
  }
190
205
 
191
206
  await runControl.waitIfPaused();
192
207
  runControl.throwIfCanceled();
193
208
  runControl.setPhase("recruit:cards");
209
+ rootState = await ensureRecruitViewport(rootState, "cards");
194
210
  cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
195
211
  timeoutMs: cardTimeoutMs,
196
212
  intervalMs: 300
@@ -209,13 +225,16 @@ export async function runRecruitWorkflow({
209
225
  unique_seen: compactInfiniteListState(listState).seen_count,
210
226
  scroll_count: 0,
211
227
  refresh_rounds: 0,
212
- refresh_attempts: 0
228
+ refresh_attempts: 0,
229
+ viewport_checks: viewportGuard.getStats().checks,
230
+ viewport_recoveries: viewportGuard.getStats().recoveries
213
231
  });
214
232
 
215
233
  while (results.length < limit) {
216
234
  await runControl.waitIfPaused();
217
235
  runControl.throwIfCanceled();
218
236
  runControl.setPhase("recruit:candidate");
237
+ rootState = await ensureRecruitViewport(rootState, "candidate_loop");
219
238
 
220
239
  const nextCandidateResult = await getNextInfiniteListCandidate({
221
240
  client,
@@ -226,7 +245,9 @@ export async function runRecruitWorkflow({
226
245
  settleMs: listSettleMs,
227
246
  fallbackPoint: listFallbackPoint,
228
247
  findNodeIds: async () => {
229
- const currentRootState = await getRecruitRoots(client);
248
+ let currentRootState = await getRecruitRoots(client);
249
+ currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");
250
+ rootState = currentRootState;
230
251
  const currentCardNodeIds = await waitForRecruitCardNodeIds(client, currentRootState.iframe.documentNodeId, {
231
252
  timeoutMs: Math.min(cardTimeoutMs, 5000),
232
253
  intervalMs: 300
@@ -283,10 +304,13 @@ export async function runRecruitWorkflow({
283
304
  refresh_attempts: refreshAttempts.length,
284
305
  refresh_method: refreshResult.method || null,
285
306
  refresh_forced_recent_viewed: true,
286
- list_end_reason: listEndReason
307
+ list_end_reason: listEndReason,
308
+ viewport_checks: viewportGuard.getStats().checks,
309
+ viewport_recoveries: viewportGuard.getStats().recoveries
287
310
  });
288
311
  if (refreshResult.ok) {
289
312
  rootState = await getRecruitRoots(client);
313
+ rootState = await ensureRecruitViewport(rootState, "refresh_after");
290
314
  cardNodeIds = await waitForRecruitCardNodeIds(client, rootState.iframe.documentNodeId, {
291
315
  timeoutMs: cardTimeoutMs,
292
316
  intervalMs: 300
@@ -318,6 +342,7 @@ export async function runRecruitWorkflow({
318
342
  await runControl.waitIfPaused();
319
343
  runControl.throwIfCanceled();
320
344
  runControl.setPhase("recruit:detail");
345
+ rootState = await ensureRecruitViewport(rootState, "detail");
321
346
  networkRecorder.clear();
322
347
  const openedDetail = await openRecruitCardDetail(client, cardNodeId);
323
348
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
@@ -430,6 +455,8 @@ export async function runRecruitWorkflow({
430
455
  refresh_rounds: refreshRounds,
431
456
  refresh_attempts: refreshAttempts.length,
432
457
  list_end_reason: listEndReason || null,
458
+ viewport_checks: viewportGuard.getStats().checks,
459
+ viewport_recoveries: viewportGuard.getStats().recoveries,
433
460
  last_candidate_id: screeningCandidate.id || null,
434
461
  last_candidate_key: candidateKey,
435
462
  last_score: screening.score
@@ -459,6 +486,10 @@ export async function runRecruitWorkflow({
459
486
  search_params: normalizedSearchParams,
460
487
  card_count: cardNodeIds.length,
461
488
  candidate_list: compactInfiniteListState(listState),
489
+ viewport_health: {
490
+ stats: viewportGuard.getStats(),
491
+ events: viewportGuard.getEvents()
492
+ },
462
493
  list_end_reason: listEndReason || null,
463
494
  refresh_rounds: refreshRounds,
464
495
  refresh_attempts: refreshAttempts,
package/src/index.js CHANGED
@@ -1911,7 +1911,8 @@ async function handleRunRecommendSelfHealTool({ workspaceRoot, args }) {
1911
1911
  domain: "recommend",
1912
1912
  roots: rootState?.roots || {},
1913
1913
  selectorProbes: config.selectorProbes,
1914
- accessibilityProbes: config.accessibilityProbes
1914
+ accessibilityProbes: config.accessibilityProbes,
1915
+ viewportProbes: config.viewportProbes
1915
1916
  });
1916
1917
  assertNoForbiddenCdpCalls(methodLog);
1917
1918
 
@@ -623,7 +623,8 @@ async function waitForHealthyRecommend(client, config, {
623
623
  domain: "recommend",
624
624
  roots: roots.roots,
625
625
  selectorProbes: config.selectorProbes,
626
- accessibilityProbes: config.accessibilityProbes
626
+ accessibilityProbes: config.accessibilityProbes,
627
+ viewportProbes: config.viewportProbes
627
628
  });
628
629
  if (lastCheck.status === HEALTH_STATUS.HEALTHY) return lastCheck;
629
630
  await sleep(intervalMs);