@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 +1 -1
- package/src/chat-mcp.js +2 -1
- package/src/core/browser/index.js +1 -0
- package/src/core/self-heal/index.js +128 -3
- package/src/core/self-heal/viewport.js +564 -0
- package/src/domains/chat/run-service.js +52 -8
- package/src/domains/recommend/roots.js +4 -2
- package/src/domains/recommend/run-service.js +37 -3
- package/src/domains/recruit/roots.js +2 -1
- package/src/domains/recruit/run-service.js +34 -3
- package/src/index.js +2 -1
- package/src/recommend-mcp.js +2 -1
package/package.json
CHANGED
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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
package/src/recommend-mcp.js
CHANGED
|
@@ -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);
|