@reconcrap/boss-recommend-mcp 2.0.37 → 2.0.39

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.
@@ -1,1715 +1,1719 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import process from "node:process";
4
- import {
5
- assertNoForbiddenCdpCalls,
6
- bringPageToFront,
7
- connectToChromeTargetOrOpen,
8
- createBossLoginRequiredError,
9
- detectBossLoginState,
10
- enableDomains,
11
- getMainFrameUrl,
12
- isBossLoginUrl,
13
- waitForMainFrameUrl,
14
- sleep
15
- } from "./core/browser/index.js";
16
- import {
17
- RUN_STATUS_CANCELING,
18
- RUN_STATUS_CANCELED,
19
- RUN_STATUS_COMPLETED,
20
- RUN_STATUS_FAILED,
21
- RUN_STATUS_PAUSED
22
- } from "./core/run/index.js";
23
- import {
24
- buildLegacyScreenInputRows,
25
- cloneReportInput,
26
- writeLegacyScreenCsv
27
- } from "./core/reporting/legacy-csv.js";
28
- import {
29
- buildRecommendSelfHealConfig,
30
- HEALTH_STATUS,
31
- resolveRecommendSelfHealRoots,
32
- runSelfHealCheck
33
- } from "./core/self-heal/index.js";
34
- import {
35
- closeRecommendJobDropdown,
36
- closeRecommendDetail,
37
- createRecommendRunService,
38
- getRecommendRoots,
39
- listRecommendJobOptions,
40
- RECOMMEND_TARGET_URL,
41
- runRecommendWorkflow
42
- } from "./domains/recommend/index.js";
43
- import {
44
- parseRecommendInstruction
45
- } from "./parser.js";
46
- import { getRunsDir } from "./run-state.js";
47
- import {
48
- resolveBossConfiguredOutputDir,
49
- resolveBossScreeningConfig
50
- } from "./chat-runtime-config.js";
51
- import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
52
-
53
- const DEFAULT_RECOMMEND_HOST = "127.0.0.1";
54
- const DEFAULT_RECOMMEND_PORT = 9222;
55
- const DEFAULT_RECOMMEND_POLL_AFTER_SEC = 10;
56
- const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
57
- const RUN_MODE_ASYNC = "async";
58
-
59
- const TERMINAL_STATUSES = new Set([
60
- RUN_STATUS_COMPLETED,
61
- RUN_STATUS_FAILED,
62
- RUN_STATUS_CANCELED
63
- ]);
64
-
65
- let recommendWorkflowImpl = runRecommendWorkflow;
66
- let recommendConnectorImpl = connectRecommendChromeSession;
67
- let recommendJobReaderImpl = readRecommendJobOptionsFromSession;
68
- let recommendRunService = createRecommendRunService({
69
- idPrefix: "mcp_recommend",
70
- workflow: (...args) => recommendWorkflowImpl(...args),
71
- onSnapshot: persistRecommendLifecycleSnapshot
72
- });
73
- const recommendRunMeta = new Map();
74
-
75
- function normalizeText(value) {
76
- return String(value || "").replace(/\s+/g, " ").trim();
77
- }
78
-
79
- function parsePositiveInteger(raw, fallback) {
80
- const parsed = Number.parseInt(String(raw || ""), 10);
81
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
- }
83
-
84
- function parseNonNegativeInteger(raw, fallback) {
85
- const parsed = Number.parseInt(String(raw ?? ""), 10);
86
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
87
- }
88
-
89
- function isDebugTestMode(args = {}) {
90
- return args.debug_test_mode === true || args.allow_debug_test_mode === true;
91
- }
92
-
93
- function normalizeScreeningModeArg(args = {}) {
94
- const raw = normalizeText(args.screening_mode || args.screeningMode || "");
95
- if (args.use_llm === false) return "deterministic";
96
- return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
97
- ? "deterministic"
98
- : "llm";
99
- }
100
-
101
- function collectRecommendDebugTestOptions(args = {}, normalized = {}) {
102
- const reasons = [];
103
- if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
104
- if (args.allow_card_only_screening === true) reasons.push("allow_card_only_screening");
105
- if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
106
- if (args.no_filter === true) reasons.push("no_filter");
107
- if (args.filter_enabled === false) reasons.push("filter_enabled=false");
108
- if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
109
- if (args.execute_post_action === false && normalized.postAction && normalized.postAction !== "none") {
110
- reasons.push("execute_post_action=false");
111
- }
112
- return reasons;
113
- }
114
-
115
- function resolveRecommendDetailLimit(args = {}, normalized = {}) {
116
- const fallback = parsePositiveInteger(normalized.targetCount, 5);
117
- const requested = parseNonNegativeInteger(args.detail_limit, fallback);
118
- if (requested === 0 && !isDebugTestMode(args)) {
119
- return fallback;
120
- }
121
- if (requested === 0 && args.allow_card_only_screening !== true) {
122
- return fallback;
123
- }
124
- return requested;
125
- }
126
-
127
- function methodSummary(methodLog = []) {
128
- const summary = {};
129
- for (const entry of methodLog || []) {
130
- summary[entry.method] = (summary[entry.method] || 0) + 1;
131
- }
132
- return summary;
133
- }
134
-
135
- function clonePlain(value, fallback = null) {
136
- try {
137
- return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
138
- } catch {
139
- return fallback;
140
- }
141
- }
142
-
143
- function plainRecord(value) {
144
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
145
- }
146
-
147
- function nonEmptyRecord(value) {
148
- const record = plainRecord(value);
149
- return Object.keys(record).length ? record : null;
150
- }
151
-
152
- function normalizeRunId(runId) {
153
- const normalized = normalizeText(runId);
154
- if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
155
- return normalized;
156
- }
157
-
158
- function getRecommendRunArtifacts(runId) {
159
- const normalized = normalizeRunId(runId);
160
- if (!normalized) return null;
161
- const runsDir = getRunsDir();
162
- const outputDir = resolveBossConfiguredOutputDir("", runsDir);
163
- return {
164
- runs_dir: runsDir,
165
- output_dir: outputDir,
166
- run_state_path: path.join(runsDir, `${normalized}.json`),
167
- checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
168
- output_csv: path.join(outputDir, `${normalized}.results.csv`),
169
- report_json: path.join(outputDir, `${normalized}.report.json`)
170
- };
171
- }
172
-
173
- function ensureDirectory(dirPath) {
174
- fs.mkdirSync(dirPath, { recursive: true });
175
- }
176
-
177
- function writeJsonAtomic(filePath, payload) {
178
- ensureDirectory(path.dirname(filePath));
179
- const tempPath = `${filePath}.tmp`;
180
- fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
181
- fs.renameSync(tempPath, filePath);
182
- }
183
-
184
- function readJsonFile(filePath) {
185
- try {
186
- if (!fs.existsSync(filePath)) return null;
187
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
188
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- function recommendSearchParamsForCsv(searchParams = {}) {
195
- return {
196
- school_tag: Object.prototype.hasOwnProperty.call(searchParams, "school_tag") ? searchParams.school_tag : "不限",
197
- degree: Object.prototype.hasOwnProperty.call(searchParams, "degree") ? searchParams.degree : "不限",
198
- gender: Object.prototype.hasOwnProperty.call(searchParams, "gender") ? searchParams.gender : "不限",
199
- recent_not_view: Object.prototype.hasOwnProperty.call(searchParams, "recent_not_view") ? searchParams.recent_not_view : "不限"
200
- };
201
- }
202
-
203
- function getSnapshotRequestContext(snapshot = {}) {
204
- const context = plainRecord(snapshot?.context);
205
- const shared = plainRecord(context.shared_run_context);
206
- return {
207
- context,
208
- confirmation: nonEmptyRecord(context.confirmation) || plainRecord(shared.confirmation),
209
- overrides: nonEmptyRecord(context.overrides) || plainRecord(shared.overrides),
210
- followUp: context.follow_up ?? shared.follow_up ?? null,
211
- shared
212
- };
213
- }
214
-
215
- function selectedRecommendJobForCsv(meta = {}, snapshot = {}) {
216
- const { confirmation, overrides, shared } = getSnapshotRequestContext(snapshot);
217
- const value = normalizeText(
218
- meta.args?.confirmation?.job_value
219
- || meta.normalized?.job
220
- || meta.args?.overrides?.job
221
- || confirmation.job_value
222
- || overrides.job
223
- || shared.confirmation?.job_value
224
- || shared.overrides?.job
225
- || shared.job_label
226
- || ""
227
- );
228
- return {
229
- value,
230
- title: value,
231
- label: value
232
- };
233
- }
234
-
235
- function buildRecommendCsvInputRows(snapshot = {}, meta = {}) {
236
- const { context, confirmation, overrides, followUp, shared } = getSnapshotRequestContext(snapshot);
237
- const searchParams = recommendSearchParamsForCsv(meta.parsed?.searchParams || {
238
- school_tag: overrides.school_tag ?? confirmation.school_tag_value,
239
- degree: overrides.degree ?? confirmation.degree_value,
240
- gender: overrides.gender ?? confirmation.gender_value,
241
- recent_not_view: overrides.recent_not_view ?? confirmation.recent_not_view_value
242
- });
243
- const parsedScreenParams = meta.parsed?.screenParams || {};
244
- const screenParams = {
245
- criteria: parsedScreenParams.criteria || meta.normalized?.criteria || overrides.criteria || "",
246
- target_count: parsedScreenParams.target_count || snapshot.progress?.target_count || meta.normalized?.targetCount || overrides.target_count || confirmation.target_count_value || shared.max_candidates || "",
247
- post_action: parsedScreenParams.post_action || overrides.post_action || confirmation.post_action_value || shared.post_action || "none",
248
- max_greet_count: parsedScreenParams.max_greet_count ?? overrides.max_greet_count ?? confirmation.max_greet_count_value ?? shared.max_greet_count ?? ""
249
- };
250
- return buildLegacyScreenInputRows({
251
- instruction: meta.args?.instruction || context.instruction || shared.instruction || "",
252
- selectedPage: "recommend",
253
- selectedJob: selectedRecommendJobForCsv(meta, snapshot),
254
- userSearchParams: cloneReportInput(searchParams, {}),
255
- effectiveSearchParams: cloneReportInput(searchParams, {}),
256
- screenParams,
257
- followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || followUp || overrides.follow_up || null
258
- });
259
- }
260
-
261
- function writeRecommendLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
262
- writeLegacyScreenCsv(filePath, {
263
- inputRows: buildRecommendCsvInputRows(snapshot, meta),
264
- results: rows
265
- });
266
- }
267
-
268
- function readRecommendRunState(runId) {
269
- const artifacts = getRecommendRunArtifacts(runId);
270
- if (!artifacts) return null;
271
- return readJsonFile(artifacts.run_state_path);
272
- }
273
-
274
- function isProcessAlive(pid) {
275
- if (!Number.isInteger(pid) || pid <= 0) return false;
276
- try {
277
- process.kill(pid, 0);
278
- return true;
279
- } catch {
280
- return false;
281
- }
282
- }
283
-
284
- function getRecommendRunMeta(runId) {
285
- return recommendRunMeta.get(runId) || {};
286
- }
287
-
288
- function toIsoOrNull(value) {
289
- const normalized = normalizeText(value);
290
- return normalized || null;
291
- }
292
-
293
- function secondsBetween(startedAt, endedAt) {
294
- const startMs = Date.parse(startedAt || "");
295
- const endMs = Date.parse(endedAt || "") || Date.now();
296
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
297
- return Math.max(1, Math.round((endMs - startMs) / 1000));
298
- }
299
-
300
- function normalizeLegacyProgress(progress = {}, summary = null) {
301
- const processed = Number.isInteger(progress.processed)
302
- ? progress.processed
303
- : Number.isInteger(summary?.processed)
304
- ? summary.processed
305
- : 0;
306
- const screened = Number.isInteger(progress.screened)
307
- ? progress.screened
308
- : Number.isInteger(summary?.screened)
309
- ? summary.screened
310
- : processed;
311
- const passed = Number.isInteger(progress.passed)
312
- ? progress.passed
313
- : Number.isInteger(summary?.passed)
314
- ? summary.passed
315
- : 0;
316
- return {
317
- ...progress,
318
- processed,
319
- inspected: processed,
320
- screened,
321
- passed,
322
- skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
323
- greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0,
324
- post_action_clicked: Number.isInteger(progress.post_action_clicked) ? progress.post_action_clicked : 0
325
- };
326
- }
327
-
328
- function completionReason(status) {
329
- if (status === RUN_STATUS_COMPLETED) return "completed";
330
- if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
331
- if (status === RUN_STATUS_FAILED) return "failed";
332
- if (status === RUN_STATUS_PAUSED) return "paused";
333
- return null;
334
- }
335
-
336
- function normalizeErrorText(error = {}) {
337
- return normalizeText([
338
- error?.code || "",
339
- error?.message || error || ""
340
- ].join(" "));
341
- }
342
-
343
- function classifyRecommendRecovery(error = {}) {
344
- const text = normalizeErrorText(error);
345
- if (!text) return null;
346
- if (/BOSS_LOGIN_REQUIRED/i.test(text)) return "login_required";
347
- if (/Could not find node with given id|No node with given id|Node is detached|Cannot find node|DETAIL_STALE_NODE|IMAGE_CAPTURE_STALE_NODE/i.test(text)) {
348
- return "transient_stale_dom";
349
- }
350
- if (/IMAGE_CAPTURE_TIMEOUT|IMAGE_CAPTURE_TOTAL_TIMEOUT|Image fallback capture timed out/i.test(text)) {
351
- return "transient_image_capture";
352
- }
353
- if (/(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i.test(text)) {
354
- return "transient_network_or_llm";
355
- }
356
- return null;
357
- }
358
-
359
- function buildConstrainedAgentRecovery(snapshot = {}, meta = {}, artifacts = null) {
360
- const error = snapshot?.error || snapshot?.result?.error || null;
361
- const classification = classifyRecommendRecovery(error);
362
- if (!classification) return null;
363
- const canRestartSameRequest = classification !== "login_required";
364
- return {
365
- policy_version: 1,
366
- classification,
367
- safe_for_outer_ai_agent: true,
368
- recommended_action: canRestartSameRequest
369
- ? "restart_same_recommend_request_only"
370
- : "ask_user_to_login_then_retry_same_recommend_request",
371
- package_requirement: "@reconcrap/boss-recommend-mcp@>=2.0.30",
372
- run_id: snapshot?.runId || snapshot?.run_id || null,
373
- retryable: true,
374
- same_request_sources: {
375
- instruction: "run.context.instruction",
376
- confirmation: "run.context.confirmation",
377
- overrides: "run.context.overrides",
378
- follow_up: "run.context.follow_up"
379
- },
380
- constraints: [
381
- "Do not change instruction, criteria, filters, job, page_scope, target_count, post_action, or max_greet_count.",
382
- "Do not switch to search/recruit/chat and do not add follow_up.chat.",
383
- "Do not summarize, translate, or rewrite criteria.",
384
- "Do not ask the user to reconfirm business choices unless Boss login is required or the stored context is missing.",
385
- "Use the same Chrome debug port and recommend page route."
386
- ],
387
- artifacts: artifacts ? {
388
- run_state_path: artifacts.run_state_path || null,
389
- checkpoint_path: artifacts.checkpoint_path || null,
390
- report_json: artifacts.report_json || null,
391
- output_csv: artifacts.output_csv || null
392
- } : null
393
- };
394
- }
395
-
396
- function ensureRecommendRunArtifacts(snapshot) {
397
- const artifacts = getRecommendRunArtifacts(snapshot?.runId || snapshot?.run_id);
398
- if (!artifacts) return null;
399
-
400
- const meta = getRecommendRunMeta(snapshot?.runId || snapshot?.run_id);
401
- const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
402
- ? snapshot.checkpoint
403
- : {};
404
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
405
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
406
-
407
- const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
408
- const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
409
- const artifactSummary = summary || (checkpointResults.length ? {
410
- domain: "recommend",
411
- partial: true,
412
- partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
413
- results: checkpointResults
414
- } : null);
415
- if (artifactSummary) {
416
- const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
417
- writeRecommendLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
418
- writeJsonAtomic(artifacts.report_json, {
419
- run_id: snapshot.runId || snapshot.run_id,
420
- status: snapshot.status || snapshot.state,
421
- phase: snapshot.phase || snapshot.stage,
422
- progress: snapshot.progress || {},
423
- context: snapshot.context || {},
424
- checkpoint,
425
- error: snapshot.error || null,
426
- last_message: snapshot.error?.message || snapshot.phase || snapshot.stage || null,
427
- recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
428
- summary: artifactSummary,
429
- generated_at: new Date().toISOString()
430
- });
431
- if (meta) {
432
- meta.outputCsvPath = artifacts.output_csv;
433
- meta.reportJsonPath = artifacts.report_json;
434
- }
435
- }
436
-
437
- return artifacts;
438
- }
439
-
440
- function persistRecommendCheckpointSnapshot(normalized) {
441
- const artifacts = getRecommendRunArtifacts(normalized?.run_id || normalized?.runId);
442
- if (!artifacts) return;
443
- const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
444
- ? normalized.checkpoint
445
- : {};
446
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
447
- const meta = getRecommendRunMeta(normalized?.run_id || normalized?.runId);
448
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
449
- }
450
-
451
- function buildLegacyRecommendResult(snapshot) {
452
- if (!snapshot) return null;
453
- const artifacts = ensureRecommendRunArtifacts(snapshot);
454
- const meta = getRecommendRunMeta(snapshot.runId);
455
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
456
- const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
457
- const resultRows = Array.isArray(summary?.results)
458
- ? summary.results
459
- : Array.isArray(checkpoint.results)
460
- ? checkpoint.results
461
- : [];
462
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
463
- const targetCount = Number.isInteger(progress.target_count)
464
- ? progress.target_count
465
- : Number.isInteger(snapshot.context?.max_candidates)
466
- ? snapshot.context.max_candidates
467
- : meta.parsed?.screenParams?.target_count || null;
468
- return {
469
- status: snapshot.status === RUN_STATUS_COMPLETED
470
- ? "COMPLETED"
471
- : snapshot.status === RUN_STATUS_CANCELED
472
- ? "CANCELED"
473
- : snapshot.status === RUN_STATUS_PAUSED
474
- ? "PAUSED"
475
- : snapshot.status === RUN_STATUS_FAILED
476
- ? "FAILED"
477
- : snapshot.status,
478
- run_id: snapshot.runId,
479
- completion_reason: completionReason(snapshot.status),
480
- requested_count: targetCount,
481
- processed_count: progress.processed,
482
- inspected_count: progress.processed,
483
- screened_count: progress.screened,
484
- passed_count: progress.passed,
485
- skipped_count: progress.skipped,
486
- detail_opened: progress.detail_opened || summary?.detail_opened || 0,
487
- greet_count: progress.greet_count || 0,
488
- post_action_clicked: progress.post_action_clicked || summary?.post_action_clicked || 0,
489
- output_csv: artifacts?.output_csv || meta.outputCsvPath || null,
490
- report_json: artifacts?.report_json || meta.reportJsonPath || null,
491
- checkpoint_path: artifacts?.checkpoint_path || meta.checkpointPath || null,
492
- started_at: snapshot.startedAt,
493
- completed_at: snapshot.completedAt || null,
494
- duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt),
495
- selected_job: {
496
- title: meta.normalized?.job || meta.args?.confirmation?.job_value || meta.args?.overrides?.job || ""
497
- },
498
- selected_page_scope: summary?.page_scope || {
499
- requested_scope: meta.normalized?.pageScope || meta.parsed?.page_scope || "recommend",
500
- effective_scope: meta.normalized?.pageScope || meta.parsed?.page_scope || "recommend"
501
- },
502
- search_params: clonePlain(meta.parsed?.searchParams || {}, {}),
503
- screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
504
- target_count_semantics: TARGET_COUNT_SEMANTICS,
505
- error: snapshot.error || null,
506
- recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
507
- results: resultRows
508
- };
509
- }
510
-
511
- function normalizeRunSnapshot(snapshot) {
512
- if (!snapshot) return null;
513
- const meta = getRecommendRunMeta(snapshot.runId);
514
- const artifacts = getRecommendRunArtifacts(snapshot.runId);
515
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
516
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
517
- const legacyResult = (
518
- TERMINAL_STATUSES.has(snapshot.status)
519
- || snapshot.status === RUN_STATUS_PAUSED
520
- ) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
521
- const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
522
- const snapshotContext = plainRecord(snapshot.context);
523
- const metaArgs = plainRecord(meta.args);
524
- const oldContext = {
525
- workspace_root: meta.workspaceRoot || snapshotContext.workspace_root || null,
526
- instruction: metaArgs.instruction || snapshotContext.instruction || "",
527
- confirmation: clonePlain(metaArgs.confirmation ?? snapshotContext.confirmation ?? {}, {}),
528
- overrides: clonePlain(metaArgs.overrides ?? snapshotContext.overrides ?? {}, {}),
529
- follow_up: clonePlain(metaArgs.follow_up ?? snapshotContext.follow_up ?? null, null),
530
- target_count_semantics: TARGET_COUNT_SEMANTICS
531
- };
532
- return {
533
- ...snapshot,
534
- progress,
535
- run_id: snapshot.runId,
536
- mode: RUN_MODE_ASYNC,
537
- state: snapshot.status,
538
- stage: snapshot.phase,
539
- started_at: snapshot.startedAt,
540
- updated_at: snapshot.updatedAt,
541
- completed_at: toIsoOrNull(snapshot.completedAt),
542
- heartbeat_at: snapshot.updatedAt,
543
- pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid || null,
544
- last_message: snapshot.error?.message || snapshot.phase || null,
545
- context: {
546
- ...snapshotContext,
547
- ...oldContext,
548
- shared_run_context: snapshotContext
549
- },
550
- control: {
551
- pause_requested: snapshot.status === RUN_STATUS_PAUSED,
552
- pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
553
- pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recommend_pipeline_run" : null,
554
- cancel_requested: snapshot.status === RUN_STATUS_CANCELING
555
- },
556
- resume: {
557
- checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
558
- pause_control_path: artifacts?.run_state_path || null,
559
- output_csv: legacyResult?.output_csv || null,
560
- resume_count: meta.resumeCount || 0,
561
- last_resumed_at: meta.lastResumedAt || null,
562
- last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
563
- },
564
- recovery,
565
- result: legacyResult,
566
- artifacts
567
- };
568
- }
569
-
570
- function mergePersistedControlRequest(normalized, existing) {
571
- const control = {
572
- ...(normalized?.control || {})
573
- };
574
- if (!normalized || TERMINAL_STATUSES.has(normalized.state)) return control;
575
- const existingControl = plainRecord(existing?.control);
576
- if (existingControl.cancel_requested === true) {
577
- return {
578
- ...control,
579
- pause_requested: true,
580
- pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
581
- pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_recommend_pipeline_run",
582
- cancel_requested: true
583
- };
584
- }
585
- if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
586
- return {
587
- ...control,
588
- pause_requested: true,
589
- pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
590
- pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_recommend_pipeline_run"
591
- };
592
- }
593
- if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
594
- return {
595
- ...control,
596
- pause_requested: false,
597
- pause_requested_at: null,
598
- pause_requested_by: null,
599
- cancel_requested: false
600
- };
601
- }
602
- return control;
603
- }
604
-
605
- function persistRecommendRunSnapshot(snapshot, {
606
- persistActiveCheckpoint = false
607
- } = {}) {
608
- const normalized = normalizeRunSnapshot(snapshot);
609
- if (!normalized?.run_id) return normalized;
610
- const artifacts = getRecommendRunArtifacts(normalized.run_id);
611
- if (!artifacts) return normalized;
612
- const existing = readJsonFile(artifacts.run_state_path);
613
- normalized.control = mergePersistedControlRequest(normalized, existing);
614
- if (persistActiveCheckpoint) {
615
- persistRecommendCheckpointSnapshot(normalized);
616
- }
617
- const payload = {
618
- run_id: normalized.run_id,
619
- mode: normalized.mode,
620
- state: normalized.state,
621
- status: normalized.status,
622
- stage: normalized.stage,
623
- started_at: normalized.started_at,
624
- updated_at: normalized.updated_at,
625
- heartbeat_at: normalized.heartbeat_at,
626
- completed_at: normalized.completed_at,
627
- pid: normalized.pid,
628
- progress: normalized.progress,
629
- last_message: normalized.last_message,
630
- context: normalized.context,
631
- control: normalized.control,
632
- resume: normalized.resume,
633
- error: normalized.error,
634
- recovery: normalized.recovery,
635
- result: normalized.result,
636
- summary: normalized.summary,
637
- artifacts: normalized.artifacts
638
- };
639
- writeJsonAtomic(artifacts.run_state_path, payload);
640
- return normalized;
641
- }
642
-
643
- function reconcilePersistedRecommendRunIfNeeded(persisted) {
644
- if (!persisted || typeof persisted !== "object") return persisted;
645
- const persistedState = normalizeText(persisted.state || persisted.status);
646
- if (TERMINAL_STATUSES.has(persistedState)) return persisted;
647
- if (isProcessAlive(persisted.pid)) return persisted;
648
-
649
- const runId = normalizeRunId(persisted.run_id || persisted.runId);
650
- const artifacts = getRecommendRunArtifacts(runId);
651
- const checkpoint = artifacts?.checkpoint_path ? readJsonFile(artifacts.checkpoint_path) : null;
652
- const now = new Date().toISOString();
653
- const error = {
654
- code: "RUN_PROCESS_EXITED",
655
- message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"}),已自动标记为失败。`,
656
- retryable: true
657
- };
658
- return persistRecommendRunSnapshot({
659
- runId,
660
- name: persisted.name || runId,
661
- status: RUN_STATUS_FAILED,
662
- phase: persisted.stage || persisted.phase || "recommend:orphaned",
663
- progress: persisted.progress || {},
664
- context: persisted.context || {},
665
- checkpoint: checkpoint || persisted.checkpoint || {},
666
- startedAt: persisted.started_at || persisted.startedAt || now,
667
- updatedAt: now,
668
- completedAt: now,
669
- pid: Number.isInteger(persisted.pid) && persisted.pid > 0 ? persisted.pid : null,
670
- error,
671
- summary: persisted.summary || null
672
- });
673
- }
674
-
675
- function persistRecommendLifecycleSnapshot(snapshot, event = {}) {
676
- return persistRecommendRunSnapshot(snapshot, {
677
- persistActiveCheckpoint: event?.type === "checkpoint"
678
- });
679
- }
680
-
681
- function attachMethodEvidence(payload, runId) {
682
- const meta = getRecommendRunMeta(runId);
683
- assertNoForbiddenCdpCalls(meta.methodLog || []);
684
- return {
685
- ...payload,
686
- runtime_evaluate_used: false,
687
- method_summary: methodSummary(meta.methodLog || []),
688
- method_log: meta.methodLog || [],
689
- chrome: meta.chrome || null
690
- };
691
- }
692
-
693
- function compactRecommendJobListOption(option, index) {
694
- const label = normalizeText(option?.label);
695
- const name = normalizeText(option?.label_without_salary || label);
696
- return {
697
- index,
698
- name,
699
- label,
700
- label_without_salary: name,
701
- current: Boolean(option?.current),
702
- visible: Boolean(option?.visible)
703
- };
704
- }
705
-
706
- async function readRecommendJobOptionsFromSession(session) {
707
- const client = session?.client;
708
- if (!client) throw new Error("Recommend Chrome session is missing a CDP client");
709
- const rootState = await getRecommendRoots(client);
710
- const frameNodeId = rootState?.iframe?.documentNodeId;
711
- if (!frameNodeId) throw new Error("recommendFrame iframe document was not found");
712
-
713
- let options = [];
714
- try {
715
- options = await listRecommendJobOptions(client, frameNodeId, {
716
- openDropdown: true
717
- });
718
- } finally {
719
- await closeRecommendJobDropdown(client).catch(() => {});
720
- }
721
-
722
- const compacted = [];
723
- const seen = new Set();
724
- for (const option of options) {
725
- const compact = compactRecommendJobListOption(option, compacted.length);
726
- if (!compact.name && !compact.label) continue;
727
- const key = `${compact.name}\n${compact.label}`;
728
- if (seen.has(key)) continue;
729
- seen.add(key);
730
- compacted.push(compact);
731
- }
732
-
733
- return {
734
- source: "recommend_job_dropdown",
735
- selector: "recommend job selection dropdown",
736
- job_options: compacted,
737
- selected_job: compacted.find((option) => option.current) || null
738
- };
739
- }
740
-
741
- export async function listRecommendJobsTool({ workspaceRoot = "", args = {} } = {}) {
742
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
743
- const host = normalizeText(args.host) || DEFAULT_RECOMMEND_HOST;
744
- const port = parsePositiveInteger(
745
- args.port,
746
- configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECOMMEND_PORT
747
- );
748
- const targetUrlIncludes = normalizeText(args.target_url_includes) || RECOMMEND_TARGET_URL;
749
- const allowNavigate = args.allow_navigate !== false;
750
- const slowLive = args.slow_live === true;
751
- let session;
752
-
753
- try {
754
- session = await recommendConnectorImpl({
755
- host,
756
- port,
757
- targetUrlIncludes,
758
- allowNavigate,
759
- slowLive
760
- });
761
-
762
- const jobs = await recommendJobReaderImpl(session, {
763
- workspaceRoot: normalizeText(workspaceRoot) || process.cwd(),
764
- args: clonePlain(args, {}),
765
- normalized: {
766
- host,
767
- port,
768
- targetUrlIncludes,
769
- allowNavigate,
770
- slowLive
771
- }
772
- });
773
- const jobOptions = Array.isArray(jobs?.job_options) ? jobs.job_options : [];
774
- assertNoForbiddenCdpCalls(session.methodLog || []);
775
- return {
776
- status: "OK",
777
- stage: "recommend_job_list",
778
- cdp_only: true,
779
- runtime_evaluate_used: false,
780
- page_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
781
- job_count: jobOptions.length,
782
- job_names: jobOptions.map((option) => option.name || option.label).filter(Boolean),
783
- job_full_labels: jobOptions.map((option) => option.label || option.name).filter(Boolean),
784
- job_options: jobOptions,
785
- selected_job: jobs?.selected_job || jobOptions.find((option) => option.current) || null,
786
- source: jobs?.source || "recommend_job_dropdown",
787
- selector: jobs?.selector || "",
788
- message: "已通过 CDP-only 从推荐页岗位下拉框读取可用岗位。Cron/一次性任务里的 job 参数优先使用 job_names 中的完整岗位名。",
789
- chrome: {
790
- host,
791
- port,
792
- target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
793
- target_id: session.target?.id || null,
794
- auto_launch: session.chrome || null
795
- },
796
- method_summary: methodSummary(session.methodLog || []),
797
- method_log: session.methodLog || []
798
- };
799
- } catch (error) {
800
- const methodLog = session?.methodLog || [];
801
- const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
802
- return {
803
- status: "FAILED",
804
- stage: "recommend_job_list",
805
- cdp_only: true,
806
- runtime_evaluate_used: methodLog.some((entry) => String(entry?.method || entry).startsWith("Runtime.")),
807
- error: {
808
- code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "RECOMMEND_JOB_LIST_FAILED",
809
- message: error?.message || "Failed to read recommend job list",
810
- requires_login: Boolean(error?.requires_login),
811
- login_url: error?.login_url || null,
812
- login_detection: error?.login_detection || null,
813
- current_url: error?.current_url || null,
814
- target_url: error?.target_url || RECOMMEND_TARGET_URL,
815
- chrome: error?.chrome || null,
816
- retryable: true
817
- },
818
- chrome: {
819
- host,
820
- port,
821
- target_url: targetUrlIncludes,
822
- auto_launch: error?.chrome || session?.chrome || null
823
- },
824
- method_summary: methodSummary(methodLog),
825
- method_log: methodLog
826
- };
827
- } finally {
828
- if (session) {
829
- try {
830
- await session.close?.();
831
- } catch {
832
- // Best-effort cleanup after a read-only helper.
833
- }
834
- }
835
- }
836
- }
837
-
838
- function compactHealth(check) {
839
- if (!check) return null;
840
- return {
841
- status: check.status,
842
- summary: check.summary,
843
- drift_report: check.drift_report,
844
- probes: (check.probes || []).map((probe) => ({
845
- id: probe.id,
846
- type: probe.type,
847
- status: probe.status,
848
- count: probe.count,
849
- required: probe.required
850
- }))
851
- };
852
- }
853
-
854
- async function waitForHealthyRecommend(client, config, {
855
- timeoutMs = 90000,
856
- intervalMs = 1000
857
- } = {}) {
858
- const started = Date.now();
859
- let lastCheck = null;
860
- while (Date.now() - started <= timeoutMs) {
861
- const loginDetection = await detectBossLoginState(client).catch(() => null);
862
- if (loginDetection?.requires_login) {
863
- return {
864
- status: "login_required",
865
- summary: "Boss login is required",
866
- loginDetection
867
- };
868
- }
869
- const roots = await resolveRecommendSelfHealRoots(client, config);
870
- lastCheck = await runSelfHealCheck({
871
- client,
872
- domain: "recommend",
873
- roots: roots.roots,
874
- selectorProbes: config.selectorProbes,
875
- accessibilityProbes: config.accessibilityProbes,
876
- viewportProbes: config.viewportProbes
877
- });
878
- if (lastCheck.status === HEALTH_STATUS.HEALTHY) return lastCheck;
879
- await sleep(intervalMs);
880
- }
881
- return lastCheck;
882
- }
883
-
884
- function shouldNavigateToRecommend(url) {
885
- return !String(url || "").includes("/web/chat/recommend");
886
- }
887
-
888
- async function connectRecommendChromeSession({
889
- host = DEFAULT_RECOMMEND_HOST,
890
- port = DEFAULT_RECOMMEND_PORT,
891
- targetUrlIncludes = RECOMMEND_TARGET_URL,
892
- allowNavigate = true,
893
- slowLive = false
894
- } = {}) {
895
- const session = await connectToChromeTargetOrOpen({
896
- host,
897
- port,
898
- targetUrlIncludes,
899
- targetUrl: RECOMMEND_TARGET_URL,
900
- allowNavigate,
901
- slowLive,
902
- fallbackTargetPredicate: (target) => (
903
- target?.type === "page"
904
- && String(target?.url || "").includes("zhipin.com")
905
- )
906
- });
907
-
908
- const { client, target } = session;
909
- await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
910
- if (typeof client?.Network?.setCacheDisabled === "function") {
911
- await client.Network.setCacheDisabled({ cacheDisabled: true });
912
- }
913
- await bringPageToFront(client);
914
-
915
- const targetUrl = String(target?.url || "");
916
- let navigation = {
917
- navigated: false,
918
- url: targetUrl
919
- };
920
- if (allowNavigate && shouldNavigateToRecommend(targetUrl)) {
921
- await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
922
- const settleMs = slowLive ? 12000 : 5000;
923
- const waited = await waitForMainFrameUrl(
924
- client,
925
- (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
926
- { timeoutMs: settleMs, intervalMs: 500 }
927
- );
928
- navigation = {
929
- navigated: true,
930
- url: RECOMMEND_TARGET_URL,
931
- settle_ms: settleMs,
932
- observed_url: waited.url || null,
933
- observed_url_ok: waited.ok
934
- };
935
- }
936
- let currentUrl = await getMainFrameUrl(client).catch(() => navigation.url || targetUrl);
937
- if (allowNavigate && shouldNavigateToRecommend(currentUrl) && !isBossLoginUrl(currentUrl)) {
938
- await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
939
- const settleMs = slowLive ? 12000 : 5000;
940
- const waited = await waitForMainFrameUrl(
941
- client,
942
- (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
943
- { timeoutMs: settleMs, intervalMs: 500 }
944
- );
945
- navigation = {
946
- navigated: true,
947
- url: RECOMMEND_TARGET_URL,
948
- settle_ms: settleMs,
949
- observed_url: waited.url || null,
950
- observed_url_ok: waited.ok,
951
- reason: "observed_url_mismatch"
952
- };
953
- currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
954
- }
955
- const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
956
- requires_login: isBossLoginUrl(currentUrl),
957
- reason: "login_detection_failed",
958
- current_url: currentUrl
959
- }));
960
- if (loginDetection.requires_login) {
961
- await session.close?.();
962
- throw createBossLoginRequiredError({
963
- domain: "recommend",
964
- currentUrl: loginDetection.current_url || currentUrl,
965
- targetUrl: RECOMMEND_TARGET_URL,
966
- loginDetection,
967
- chrome: session.chrome || null
968
- });
969
- }
970
- if (shouldNavigateToRecommend(currentUrl)) {
971
- await session.close?.();
972
- throw new Error(`Boss recommend page did not navigate to ${RECOMMEND_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
973
- }
974
-
975
- const selfHealConfig = buildRecommendSelfHealConfig();
976
- const health = await waitForHealthyRecommend(client, selfHealConfig, {
977
- timeoutMs: slowLive ? 180000 : 90000,
978
- intervalMs: slowLive ? 1200 : 800
979
- });
980
- if (health?.loginDetection?.requires_login) {
981
- await session.close?.();
982
- throw createBossLoginRequiredError({
983
- domain: "recommend",
984
- currentUrl: health.loginDetection.current_url || currentUrl,
985
- targetUrl: RECOMMEND_TARGET_URL,
986
- loginDetection: health.loginDetection,
987
- chrome: session.chrome || null
988
- });
989
- }
990
- if (!health || health.status !== HEALTH_STATUS.HEALTHY) {
991
- const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
992
- const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
993
- requires_login: isBossLoginUrl(latestUrl),
994
- reason: "login_detection_failed",
995
- current_url: latestUrl
996
- }));
997
- if (latestLoginDetection.requires_login) {
998
- await session.close?.();
999
- throw createBossLoginRequiredError({
1000
- domain: "recommend",
1001
- currentUrl: latestLoginDetection.current_url || latestUrl,
1002
- targetUrl: RECOMMEND_TARGET_URL,
1003
- loginDetection: latestLoginDetection,
1004
- chrome: session.chrome || null
1005
- });
1006
- }
1007
- throw new Error(`Boss recommend page is not healthy: ${health?.status || "missing"}`);
1008
- }
1009
-
1010
- return {
1011
- ...session,
1012
- navigation,
1013
- health
1014
- };
1015
- }
1016
-
1017
- function parseRecommendPipelineRequest(args = {}) {
1018
- return parseRecommendInstruction({
1019
- instruction: args.instruction,
1020
- confirmation: args.confirmation,
1021
- overrides: args.overrides
1022
- });
1023
- }
1024
-
1025
- function buildRequiredConfirmations(parsed, args = {}) {
1026
- const required = [];
1027
- if (parsed.needs_page_confirmation) required.push("page_scope");
1028
- if (parsed.needs_filters_confirmation) required.push("filters");
1029
- if (parsed.needs_school_tag_confirmation) required.push("school_tag");
1030
- if (parsed.needs_degree_confirmation) required.push("degree");
1031
- if (parsed.needs_gender_confirmation) required.push("gender");
1032
- if (parsed.needs_recent_not_view_confirmation) required.push("recent_not_view");
1033
- if (parsed.needs_criteria_confirmation) required.push("criteria");
1034
- if (parsed.needs_target_count_confirmation) required.push("target_count");
1035
- if (parsed.needs_post_action_confirmation) required.push("post_action");
1036
- if (parsed.needs_max_greet_count_confirmation) required.push("max_greet_count");
1037
- if ((parsed.suspicious_fields || []).length) required.push("suspicious_fields");
1038
-
1039
- const confirmation = args.confirmation || {};
1040
- const jobValue = normalizeText(confirmation.job_value || args.overrides?.job || "");
1041
- if (confirmation.job_confirmed !== true || !jobValue) required.push("job");
1042
- if (confirmation.final_confirmed !== true) required.push("final_review");
1043
- return Array.from(new Set(required));
1044
- }
1045
-
1046
- function buildJobPendingQuestion(args = {}) {
1047
- const value = normalizeText(args.confirmation?.job_value || args.overrides?.job || "");
1048
- return {
1049
- field: "job",
1050
- question: "请确认推荐页岗位。CDP-only rewrite 会先切换到该岗位,再按所选页面范围执行筛选。",
1051
- value: value || null
1052
- };
1053
- }
1054
-
1055
- function buildFinalReviewQuestion(parsed) {
1056
- return {
1057
- field: "final_review",
1058
- question: "请最终确认本次推荐页筛选参数无误,并明确 final_confirmed=true 后再启动。",
1059
- value: {
1060
- page_scope: parsed.page_scope,
1061
- search_params: parsed.searchParams,
1062
- screen_params: parsed.screenParams
1063
- }
1064
- };
1065
- }
1066
-
1067
- function buildNeedInputResponse(parsed) {
1068
- return {
1069
- status: "NEED_INPUT",
1070
- missing_fields: parsed.missing_fields,
1071
- required_confirmations: buildRequiredConfirmations(parsed),
1072
- search_params: parsed.searchParams,
1073
- screen_params: parsed.screenParams,
1074
- pending_questions: parsed.pending_questions,
1075
- review: parsed.review,
1076
- error: {
1077
- code: "MISSING_REQUIRED_FIELDS",
1078
- message: "缺少必要字段。请补齐推荐页 criteria 等必填字段后再启动 CDP-only recommend run。",
1079
- retryable: true
1080
- }
1081
- };
1082
- }
1083
-
1084
- function buildNeedConfirmationResponse(parsed, args, requiredConfirmations) {
1085
- const pending = [...(parsed.pending_questions || [])];
1086
- if (requiredConfirmations.includes("job") && !pending.some((item) => item.field === "job")) {
1087
- pending.push(buildJobPendingQuestion(args));
1088
- }
1089
- if (requiredConfirmations.includes("final_review") && !pending.some((item) => item.field === "final_review")) {
1090
- pending.push(buildFinalReviewQuestion(parsed));
1091
- }
1092
- return {
1093
- status: "NEED_CONFIRMATION",
1094
- required_confirmations: requiredConfirmations,
1095
- page_scope: parsed.page_scope,
1096
- search_params: parsed.searchParams,
1097
- screen_params: parsed.screenParams,
1098
- pending_questions: pending,
1099
- review: {
1100
- ...(parsed.review || {}),
1101
- required_confirmations: requiredConfirmations
1102
- }
1103
- };
1104
- }
1105
-
1106
- function evaluateRecommendPipelineGate(parsed, args = {}) {
1107
- if (parsed.missing_fields?.length) return buildNeedInputResponse(parsed);
1108
- const requiredConfirmations = buildRequiredConfirmations(parsed, args);
1109
- if (requiredConfirmations.length) {
1110
- return buildNeedConfirmationResponse(parsed, args, requiredConfirmations);
1111
- }
1112
-
1113
- if (args.follow_up?.chat || args.overrides?.follow_up?.chat) {
1114
- return {
1115
- status: "FAILED",
1116
- error: {
1117
- code: "FOLLOW_UP_CHAT_NOT_CDP_REWRITTEN",
1118
- message: "recommend -> chat follow-up orchestration is legacy-only and intentionally fenced from the CDP-only MCP route. Run recommend first, then use the direct chat MCP route separately, or keep the old chained behavior in the archived legacy lane.",
1119
- retryable: true
1120
- },
1121
- review: parsed.review
1122
- };
1123
- }
1124
-
1125
- return null;
1126
- }
1127
-
1128
- function toArray(value) {
1129
- if (Array.isArray(value)) return value;
1130
- if (value === undefined || value === null) return [];
1131
- return [value];
1132
- }
1133
-
1134
- function withoutUnlimited(values = []) {
1135
- return toArray(values)
1136
- .map((value) => normalizeText(value))
1137
- .filter((value) => value && value !== "不限" && value.toLowerCase() !== "all" && value !== "全部");
1138
- }
1139
-
1140
- function buildRecommendFilter(parsed, args = {}) {
1141
- if (args.no_filter === true || args.filter_enabled === false) {
1142
- return { enabled: false };
1143
- }
1144
-
1145
- const groups = [];
1146
- const recentNotView = withoutUnlimited(parsed.searchParams?.recent_not_view);
1147
- if (recentNotView.length) {
1148
- groups.push({
1149
- group: "recentNotView",
1150
- labels: recentNotView,
1151
- selectAllLabels: true
1152
- });
1153
- }
1154
-
1155
- const degree = withoutUnlimited(parsed.searchParams?.degree);
1156
- if (degree.length) {
1157
- groups.push({
1158
- group: "degree",
1159
- labels: degree,
1160
- selectAllLabels: true
1161
- });
1162
- }
1163
-
1164
- const gender = withoutUnlimited(parsed.searchParams?.gender);
1165
- if (gender.length) {
1166
- groups.push({
1167
- group: "gender",
1168
- labels: gender,
1169
- selectAllLabels: true
1170
- });
1171
- }
1172
-
1173
- const school = withoutUnlimited(parsed.searchParams?.school_tag);
1174
- if (school.length) {
1175
- groups.push({
1176
- group: "school",
1177
- labels: school,
1178
- selectAllLabels: true
1179
- });
1180
- }
1181
-
1182
- return groups.length ? { filterGroups: groups } : { enabled: false };
1183
- }
1184
-
1185
- function normalizeRecommendStartInput(args = {}, parsed, configResolution = null) {
1186
- const confirmation = args.confirmation || {};
1187
- const overrides = args.overrides || {};
1188
- const slowLive = args.slow_live === true;
1189
- const targetCount = parsePositiveInteger(
1190
- args.max_candidates,
1191
- parsed.screenParams?.target_count || parsePositiveInteger(confirmation.target_count_value, 5)
1192
- );
1193
- return {
1194
- host: normalizeText(args.host) || DEFAULT_RECOMMEND_HOST,
1195
- port: parsePositiveInteger(
1196
- args.port,
1197
- configResolution?.ok ? configResolution.config.debugPort : DEFAULT_RECOMMEND_PORT
1198
- ),
1199
- targetUrlIncludes: normalizeText(args.target_url_includes) || RECOMMEND_TARGET_URL,
1200
- allowNavigate: args.allow_navigate !== false,
1201
- slowLive,
1202
- criteria: parsed.screenParams?.criteria || normalizeText(overrides.criteria),
1203
- targetCount,
1204
- job: normalizeText(confirmation.job_value || overrides.job || ""),
1205
- pageScope: parsed.page_scope || "recommend",
1206
- filter: buildRecommendFilter(parsed, args),
1207
- postAction: parsed.screenParams?.post_action || "none",
1208
- maxGreetCount: Number.isInteger(parsed.screenParams?.max_greet_count)
1209
- ? parsed.screenParams.max_greet_count
1210
- : null,
1211
- screeningMode: normalizeScreeningModeArg(args)
1212
- };
1213
- }
1214
-
1215
- function getRunOptions(args, parsed, normalized, session, configResolution = null) {
1216
- const slowLive = args.slow_live === true;
1217
- const executePostAction = args.dry_run_post_action === true
1218
- ? false
1219
- : args.execute_post_action !== false;
1220
- return {
1221
- client: session.client,
1222
- targetUrl: RECOMMEND_TARGET_URL,
1223
- criteria: normalized.criteria,
1224
- jobLabel: normalized.job,
1225
- pageScope: normalized.pageScope,
1226
- fallbackPageScope: "recommend",
1227
- filter: normalized.filter,
1228
- maxCandidates: normalized.targetCount,
1229
- detailLimit: resolveRecommendDetailLimit(args, normalized),
1230
- closeDetail: true,
1231
- delayMs: parseNonNegativeInteger(args.delay_ms, 0),
1232
- cardTimeoutMs: slowLive ? 180000 : 90000,
1233
- maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
1234
- imageWheelDeltaY: parsePositiveInteger(args.image_wheel_delta_y, 650),
1235
- cvAcquisitionMode: normalizeText(args.cv_acquisition_mode) || "unknown",
1236
- listMaxScrolls: parsePositiveInteger(args.list_max_scrolls, 20),
1237
- listStableSignatureLimit: parsePositiveInteger(args.list_stable_signature_limit, 2),
1238
- listWheelDeltaY: parsePositiveInteger(args.list_wheel_delta_y, 850),
1239
- listSettleMs: parsePositiveInteger(args.list_settle_ms, slowLive ? 1800 : 1200),
1240
- listFallbackPoint: null,
1241
- refreshOnEnd: args.refresh_on_end !== false,
1242
- maxRefreshRounds: parseNonNegativeInteger(args.max_refresh_rounds, 2),
1243
- refreshButtonSettleMs: parsePositiveInteger(args.refresh_button_settle_ms, slowLive ? 10000 : 8000),
1244
- refreshReloadSettleMs: parsePositiveInteger(args.refresh_reload_settle_ms, slowLive ? 12000 : 8000),
1245
- postAction: normalized.postAction,
1246
- maxGreetCount: normalized.maxGreetCount,
1247
- executePostAction,
1248
- actionTimeoutMs: parsePositiveInteger(args.action_timeout_ms, slowLive ? 12000 : 8000),
1249
- actionIntervalMs: parsePositiveInteger(args.action_interval_ms, 500),
1250
- actionAfterClickDelayMs: parseNonNegativeInteger(args.action_after_click_delay_ms, slowLive ? 1200 : 900),
1251
- screeningMode: normalized.screeningMode,
1252
- llmConfig: normalized.screeningMode === "llm" && configResolution?.ok ? {
1253
- ...configResolution.config
1254
- } : null,
1255
- llmTimeoutMs: parsePositiveInteger(
1256
- args.llm_timeout_ms,
1257
- parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
1258
- ),
1259
- llmImageLimit: parsePositiveInteger(
1260
- args.llm_image_limit,
1261
- parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
1262
- ),
1263
- llmImageDetail: normalizeText(
1264
- args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
1265
- ) || "low",
1266
- imageOutputDir: resolveBossConfiguredOutputDir("", getRunsDir()),
1267
- name: "mcp-recommend-pipeline-run",
1268
- parsed
1269
- };
1270
- }
1271
-
1272
- function prepareRecommendPipelineStart(args = {}, { workspaceRoot = "" } = {}) {
1273
- const parsed = parseRecommendPipelineRequest(args);
1274
- const gate = evaluateRecommendPipelineGate(parsed, args);
1275
- if (gate) return { response: gate };
1276
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
1277
- const normalized = normalizeRecommendStartInput(args, parsed, configResolution);
1278
- const debugTestOptions = collectRecommendDebugTestOptions(args, normalized);
1279
- if (debugTestOptions.length && !isDebugTestMode(args)) {
1280
- return {
1281
- response: {
1282
- status: "FAILED",
1283
- error: {
1284
- code: "DEBUG_TEST_MODE_REQUIRED",
1285
- message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1286
- retryable: false
1287
- },
1288
- debug_test_options: debugTestOptions
1289
- }
1290
- };
1291
- }
1292
- if (normalized.screeningMode === "llm" && !configResolution.ok) {
1293
- return {
1294
- response: {
1295
- status: "FAILED",
1296
- error: {
1297
- code: "SCREEN_CONFIG_ERROR",
1298
- message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1299
- retryable: true
1300
- },
1301
- config_path: configResolution.config_path || null,
1302
- candidate_paths: configResolution.candidate_paths || []
1303
- }
1304
- };
1305
- }
1306
- return {
1307
- parsed,
1308
- configResolution,
1309
- normalized
1310
- };
1311
- }
1312
-
1313
- async function closeRecommendRunSession(runId) {
1314
- const meta = recommendRunMeta.get(runId);
1315
- if (!meta || meta.closed) return;
1316
- try {
1317
- try {
1318
- if (meta.session?.client) {
1319
- await closeRecommendDetail(meta.session.client, { attemptsLimit: 2 });
1320
- }
1321
- } catch {
1322
- // Cleanup is best-effort once the run has settled.
1323
- }
1324
- assertNoForbiddenCdpCalls(meta.methodLog || []);
1325
- } finally {
1326
- meta.closed = true;
1327
- try {
1328
- await meta.session?.close?.();
1329
- } catch {
1330
- // Nothing actionable for the caller once the run has settled.
1331
- }
1332
- }
1333
- }
1334
-
1335
- async function waitForRecommendRunTerminal(runId) {
1336
- while (true) {
1337
- try {
1338
- const snapshot = recommendRunService.getRecommendRun(runId);
1339
- if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
1340
- } catch {
1341
- return null;
1342
- }
1343
- await sleep(1000);
1344
- }
1345
- }
1346
-
1347
- function trackRecommendRun(runId) {
1348
- waitForRecommendRunTerminal(runId)
1349
- .then((terminal) => {
1350
- if (terminal) persistRecommendRunSnapshot(terminal);
1351
- })
1352
- .catch(() => null)
1353
- .finally(() => {
1354
- closeRecommendRunSession(runId).catch(() => {});
1355
- });
1356
- }
1357
-
1358
- async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
1359
- const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1360
- if (prepared.response) return prepared.response;
1361
- const { parsed, configResolution, normalized } = prepared;
1362
- const fixedRunId = normalizeRunId(runId);
1363
- if (runId && !fixedRunId) {
1364
- return {
1365
- status: "FAILED",
1366
- error: {
1367
- code: "INVALID_RUN_ID",
1368
- message: "run_id is invalid",
1369
- retryable: false
1370
- }
1371
- };
1372
- }
1373
-
1374
- let session;
1375
- try {
1376
- session = await recommendConnectorImpl({
1377
- host: normalized.host,
1378
- port: normalized.port,
1379
- targetUrlIncludes: normalized.targetUrlIncludes,
1380
- allowNavigate: normalized.allowNavigate,
1381
- slowLive: normalized.slowLive
1382
- });
1383
- } catch (error) {
1384
- const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1385
- return {
1386
- status: "FAILED",
1387
- error: {
1388
- code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_RECOMMEND_PAGE_NOT_READY",
1389
- message: error?.message || "Boss recommend page is not ready",
1390
- requires_login: Boolean(error?.requires_login),
1391
- login_url: error?.login_url || null,
1392
- login_detection: error?.login_detection || null,
1393
- chrome: error?.chrome || null,
1394
- current_url: error?.current_url || null,
1395
- target_url: error?.target_url || RECOMMEND_TARGET_URL,
1396
- retryable: true
1397
- },
1398
- chrome: error?.chrome || null
1399
- };
1400
- }
1401
-
1402
- let started;
1403
- try {
1404
- started = recommendRunService.startRecommendRun({
1405
- ...getRunOptions(args, parsed, normalized, session, configResolution),
1406
- runId: fixedRunId || undefined,
1407
- pid: process.pid
1408
- });
1409
- } catch (error) {
1410
- await session.close?.();
1411
- return {
1412
- status: "FAILED",
1413
- error: {
1414
- code: "RECOMMEND_RUN_START_FAILED",
1415
- message: error?.message || "Failed to start recommend run",
1416
- retryable: true
1417
- }
1418
- };
1419
- }
1420
-
1421
- recommendRunMeta.set(started.runId, {
1422
- session,
1423
- methodLog: session.methodLog || [],
1424
- workspaceRoot: normalizeText(workspaceRoot) || process.cwd(),
1425
- args: clonePlain(args, {}),
1426
- normalized,
1427
- parsed,
1428
- chrome: {
1429
- host: normalized.host,
1430
- port: normalized.port,
1431
- target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
1432
- target_id: session.target?.id || null,
1433
- auto_launch: session.chrome || null
1434
- },
1435
- health: session.health || null
1436
- });
1437
- trackRecommendRun(started.runId);
1438
- const persistedStarted = persistRecommendRunSnapshot(started);
1439
-
1440
- return {
1441
- status: "ACCEPTED",
1442
- run_id: persistedStarted.run_id,
1443
- state: persistedStarted.state,
1444
- run: persistedStarted,
1445
- poll_after_sec: DEFAULT_RECOMMEND_POLL_AFTER_SEC,
1446
- review: parsed.review,
1447
- message: normalized.postAction === "none"
1448
- ? "Recommend pipeline run started through the shared CDP-only recommend service. No post-action was requested."
1449
- : `Recommend pipeline run started through the shared CDP-only recommend service with post_action=${normalized.postAction}${args.dry_run_post_action === true ? " in dry-run mode" : ""}.`,
1450
- post_action: {
1451
- requested: normalized.postAction,
1452
- execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1453
- max_greet_count: normalized.maxGreetCount
1454
- },
1455
- target_count_semantics: TARGET_COUNT_SEMANTICS
1456
- };
1457
- }
1458
-
1459
- export function prepareRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1460
- const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1461
- if (prepared.response) return prepared.response;
1462
- const { parsed, normalized } = prepared;
1463
- return {
1464
- status: "READY",
1465
- review: parsed.review,
1466
- post_action: {
1467
- requested: normalized.postAction,
1468
- execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1469
- max_greet_count: normalized.maxGreetCount
1470
- },
1471
- target_count_semantics: TARGET_COUNT_SEMANTICS
1472
- };
1473
- }
1474
-
1475
- export async function startRecommendPipelineRunTool({ workspaceRoot = "", args = {}, runId = "" } = {}) {
1476
- const started = await startRecommendPipelineRunInternal(args, { workspaceRoot, runId });
1477
- if (started.status !== "ACCEPTED") return started;
1478
- return attachMethodEvidence(started, started.run_id);
1479
- }
1480
-
1481
- export function getRecommendPipelineRunTool({ args = {} } = {}) {
1482
- const runId = normalizeRunId(args.run_id || args.runId);
1483
- if (!runId) {
1484
- return {
1485
- status: "FAILED",
1486
- error: {
1487
- code: "INVALID_RUN_ID",
1488
- message: "run_id is required",
1489
- retryable: false
1490
- }
1491
- };
1492
- }
1493
- try {
1494
- const run = recommendRunService.getRecommendRun(runId);
1495
- const normalizedRun = persistRecommendRunSnapshot(run);
1496
- return attachMethodEvidence({
1497
- status: "RUN_STATUS",
1498
- run: normalizedRun
1499
- }, runId);
1500
- } catch {
1501
- const persisted = readRecommendRunState(runId);
1502
- if (persisted) {
1503
- const reconciled = reconcilePersistedRecommendRunIfNeeded(persisted);
1504
- return {
1505
- status: "RUN_STATUS",
1506
- run: reconciled,
1507
- persistence: {
1508
- source: "disk",
1509
- active_control_available: false,
1510
- stale_process_reconciled: reconciled?.state !== persisted.state
1511
- },
1512
- runtime_evaluate_used: false,
1513
- method_summary: {},
1514
- method_log: [],
1515
- chrome: null
1516
- };
1517
- }
1518
- return {
1519
- status: "FAILED",
1520
- error: {
1521
- code: "RUN_NOT_FOUND",
1522
- message: `No recommend run found for run_id=${runId}`,
1523
- retryable: false
1524
- }
1525
- };
1526
- }
1527
- }
1528
-
1529
- export function pauseRecommendPipelineRunTool({ args = {} } = {}) {
1530
- const runId = normalizeRunId(args.run_id || args.runId);
1531
- try {
1532
- const before = recommendRunService.getRecommendRun(runId);
1533
- if (TERMINAL_STATUSES.has(before.status)) {
1534
- const normalizedBefore = persistRecommendRunSnapshot(before);
1535
- return attachMethodEvidence({
1536
- status: "PAUSE_IGNORED",
1537
- run: normalizedBefore,
1538
- message: "目标任务已结束,无需暂停。"
1539
- }, runId);
1540
- }
1541
- if (before.status === RUN_STATUS_PAUSED) {
1542
- const normalizedBefore = persistRecommendRunSnapshot(before);
1543
- return attachMethodEvidence({
1544
- status: "PAUSE_IGNORED",
1545
- run: normalizedBefore,
1546
- message: "目标任务已经处于 paused 状态。"
1547
- }, runId);
1548
- }
1549
- const run = recommendRunService.pauseRecommendRun(runId);
1550
- const normalizedRun = persistRecommendRunSnapshot(run);
1551
- return attachMethodEvidence({
1552
- status: "PAUSE_REQUESTED",
1553
- run: normalizedRun,
1554
- message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1555
- }, runId);
1556
- } catch {
1557
- const persisted = readRecommendRunState(runId);
1558
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1559
- return {
1560
- status: "PAUSE_IGNORED",
1561
- run: persisted,
1562
- message: "目标任务已结束,无需暂停。",
1563
- runtime_evaluate_used: false,
1564
- method_summary: {},
1565
- method_log: [],
1566
- chrome: null
1567
- };
1568
- }
1569
- return getRecommendPipelineRunTool({ args });
1570
- }
1571
- }
1572
-
1573
- export function resumeRecommendPipelineRunTool({ args = {} } = {}) {
1574
- const runId = normalizeRunId(args.run_id || args.runId);
1575
- try {
1576
- const before = recommendRunService.getRecommendRun(runId);
1577
- if (TERMINAL_STATUSES.has(before.status)) {
1578
- const normalizedBefore = persistRecommendRunSnapshot(before);
1579
- return attachMethodEvidence({
1580
- status: "FAILED",
1581
- error: {
1582
- code: "RUN_ALREADY_TERMINATED",
1583
- message: "目标任务已结束,无法继续。",
1584
- retryable: false
1585
- },
1586
- run: normalizedBefore
1587
- }, runId);
1588
- }
1589
- if (before.status !== RUN_STATUS_PAUSED) {
1590
- const normalizedBefore = persistRecommendRunSnapshot(before);
1591
- return attachMethodEvidence({
1592
- status: "FAILED",
1593
- error: {
1594
- code: "RUN_NOT_PAUSED",
1595
- message: "仅 paused 状态的 run 才能继续。",
1596
- retryable: true
1597
- },
1598
- run: normalizedBefore
1599
- }, runId);
1600
- }
1601
- const run = recommendRunService.resumeRecommendRun(runId);
1602
- const meta = getRecommendRunMeta(runId);
1603
- if (meta) {
1604
- meta.resumeCount = (meta.resumeCount || 0) + 1;
1605
- meta.lastResumedAt = new Date().toISOString();
1606
- }
1607
- const normalizedRun = persistRecommendRunSnapshot(run);
1608
- return attachMethodEvidence({
1609
- status: "RESUME_REQUESTED",
1610
- run: normalizedRun,
1611
- poll_after_sec: DEFAULT_RECOMMEND_POLL_AFTER_SEC,
1612
- message: "已恢复 Recommend run,请使用 get_recommend_pipeline_run 按需轮询。"
1613
- }, runId);
1614
- } catch {
1615
- const persisted = readRecommendRunState(runId);
1616
- if (persisted) {
1617
- return {
1618
- status: "FAILED",
1619
- error: {
1620
- code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1621
- message: TERMINAL_STATUSES.has(persisted.state)
1622
- ? "目标任务已结束,无法继续。"
1623
- : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1624
- retryable: !TERMINAL_STATUSES.has(persisted.state)
1625
- },
1626
- run: persisted,
1627
- persistence: {
1628
- source: "disk",
1629
- active_control_available: false
1630
- },
1631
- runtime_evaluate_used: false,
1632
- method_summary: {},
1633
- method_log: [],
1634
- chrome: null
1635
- };
1636
- }
1637
- return getRecommendPipelineRunTool({ args });
1638
- }
1639
- }
1640
-
1641
- export function cancelRecommendPipelineRunTool({ args = {} } = {}) {
1642
- const runId = normalizeRunId(args.run_id || args.runId);
1643
- try {
1644
- const before = recommendRunService.getRecommendRun(runId);
1645
- if (TERMINAL_STATUSES.has(before.status)) {
1646
- const normalizedBefore = persistRecommendRunSnapshot(before);
1647
- return attachMethodEvidence({
1648
- status: "CANCEL_IGNORED",
1649
- run: normalizedBefore,
1650
- message: "目标任务已结束,无需取消。"
1651
- }, runId);
1652
- }
1653
- const run = recommendRunService.cancelRecommendRun(runId);
1654
- const normalizedRun = persistRecommendRunSnapshot(run);
1655
- return attachMethodEvidence({
1656
- status: "CANCEL_REQUESTED",
1657
- run: normalizedRun,
1658
- message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
1659
- }, runId);
1660
- } catch {
1661
- const persisted = readRecommendRunState(runId);
1662
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1663
- return {
1664
- status: "CANCEL_IGNORED",
1665
- run: persisted,
1666
- message: "目标任务已结束,无需取消。",
1667
- runtime_evaluate_used: false,
1668
- method_summary: {},
1669
- method_log: [],
1670
- chrome: null
1671
- };
1672
- }
1673
- return getRecommendPipelineRunTool({ args });
1674
- }
1675
- }
1676
-
1677
- export function getRecommendMcpHealthSnapshot(runId) {
1678
- const meta = getRecommendRunMeta(runId);
1679
- return {
1680
- health: compactHealth(meta.health || null),
1681
- chrome: meta.chrome || null,
1682
- method_summary: methodSummary(meta.methodLog || [])
1683
- };
1684
- }
1685
-
1686
- export function __setRecommendMcpConnectorForTests(nextConnector) {
1687
- recommendConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecommendChromeSession;
1688
- }
1689
-
1690
- export function __setRecommendMcpJobReaderForTests(nextReader) {
1691
- recommendJobReaderImpl = typeof nextReader === "function" ? nextReader : readRecommendJobOptionsFromSession;
1692
- }
1693
-
1694
- export function __setRecommendMcpWorkflowForTests(nextWorkflow) {
1695
- recommendWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecommendWorkflow;
1696
- recommendRunService = createRecommendRunService({
1697
- idPrefix: "mcp_recommend",
1698
- workflow: (...args) => recommendWorkflowImpl(...args),
1699
- onSnapshot: persistRecommendLifecycleSnapshot
1700
- });
1701
- }
1702
-
1703
- export function __resetRecommendMcpStateForTests() {
1704
- for (const meta of recommendRunMeta.values()) {
1705
- try {
1706
- meta.session?.close?.();
1707
- } catch {
1708
- // Best-effort test cleanup.
1709
- }
1710
- }
1711
- recommendRunMeta.clear();
1712
- __setRecommendMcpConnectorForTests(null);
1713
- __setRecommendMcpJobReaderForTests(null);
1714
- __setRecommendMcpWorkflowForTests(null);
1715
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import {
5
+ assertNoForbiddenCdpCalls,
6
+ bringPageToFront,
7
+ connectToChromeTargetOrOpen,
8
+ createBossLoginRequiredError,
9
+ detectBossLoginState,
10
+ enableDomains,
11
+ getMainFrameUrl,
12
+ isBossLoginUrl,
13
+ waitForMainFrameUrl,
14
+ sleep
15
+ } from "./core/browser/index.js";
16
+ import {
17
+ RUN_STATUS_CANCELING,
18
+ RUN_STATUS_CANCELED,
19
+ RUN_STATUS_COMPLETED,
20
+ RUN_STATUS_FAILED,
21
+ RUN_STATUS_PAUSED
22
+ } from "./core/run/index.js";
23
+ import {
24
+ buildLegacyScreenInputRows,
25
+ cloneReportInput,
26
+ writeLegacyScreenCsv
27
+ } from "./core/reporting/legacy-csv.js";
28
+ import {
29
+ buildRecommendSelfHealConfig,
30
+ HEALTH_STATUS,
31
+ resolveRecommendSelfHealRoots,
32
+ runSelfHealCheck
33
+ } from "./core/self-heal/index.js";
34
+ import {
35
+ closeRecommendJobDropdown,
36
+ closeRecommendDetail,
37
+ createRecommendRunService,
38
+ getRecommendRoots,
39
+ listRecommendJobOptions,
40
+ RECOMMEND_TARGET_URL,
41
+ runRecommendWorkflow
42
+ } from "./domains/recommend/index.js";
43
+ import {
44
+ parseRecommendInstruction
45
+ } from "./parser.js";
46
+ import { getRunsDir } from "./run-state.js";
47
+ import {
48
+ resolveBossConfiguredOutputDir,
49
+ resolveBossScreeningConfig
50
+ } from "./chat-runtime-config.js";
51
+ import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
52
+
53
+ const DEFAULT_RECOMMEND_HOST = "127.0.0.1";
54
+ const DEFAULT_RECOMMEND_PORT = 9222;
55
+ const DEFAULT_RECOMMEND_POLL_AFTER_SEC = 10;
56
+ const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
57
+ const RUN_MODE_ASYNC = "async";
58
+
59
+ const TERMINAL_STATUSES = new Set([
60
+ RUN_STATUS_COMPLETED,
61
+ RUN_STATUS_FAILED,
62
+ RUN_STATUS_CANCELED
63
+ ]);
64
+
65
+ let recommendWorkflowImpl = runRecommendWorkflow;
66
+ let recommendConnectorImpl = connectRecommendChromeSession;
67
+ let recommendJobReaderImpl = readRecommendJobOptionsFromSession;
68
+ let recommendRunService = createRecommendRunService({
69
+ idPrefix: "mcp_recommend",
70
+ workflow: (...args) => recommendWorkflowImpl(...args),
71
+ onSnapshot: persistRecommendLifecycleSnapshot
72
+ });
73
+ const recommendRunMeta = new Map();
74
+
75
+ function normalizeText(value) {
76
+ return String(value || "").replace(/\s+/g, " ").trim();
77
+ }
78
+
79
+ function parsePositiveInteger(raw, fallback) {
80
+ const parsed = Number.parseInt(String(raw || ""), 10);
81
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
+ }
83
+
84
+ function parseNonNegativeInteger(raw, fallback) {
85
+ const parsed = Number.parseInt(String(raw ?? ""), 10);
86
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
87
+ }
88
+
89
+ function isDebugTestMode(args = {}) {
90
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
91
+ }
92
+
93
+ function normalizeScreeningModeArg(args = {}) {
94
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
95
+ if (args.use_llm === false) return "deterministic";
96
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
97
+ ? "deterministic"
98
+ : "llm";
99
+ }
100
+
101
+ function collectRecommendDebugTestOptions(args = {}, normalized = {}) {
102
+ const reasons = [];
103
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
104
+ if (args.allow_card_only_screening === true) reasons.push("allow_card_only_screening");
105
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
106
+ if (args.no_filter === true) reasons.push("no_filter");
107
+ if (args.filter_enabled === false) reasons.push("filter_enabled=false");
108
+ if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
109
+ if (args.execute_post_action === false && normalized.postAction && normalized.postAction !== "none") {
110
+ reasons.push("execute_post_action=false");
111
+ }
112
+ return reasons;
113
+ }
114
+
115
+ function resolveRecommendDetailLimit(args = {}, normalized = {}) {
116
+ const fallback = parsePositiveInteger(normalized.targetCount, 5);
117
+ const requested = parseNonNegativeInteger(args.detail_limit, fallback);
118
+ if (requested === 0 && !isDebugTestMode(args)) {
119
+ return fallback;
120
+ }
121
+ if (requested === 0 && args.allow_card_only_screening !== true) {
122
+ return fallback;
123
+ }
124
+ return requested;
125
+ }
126
+
127
+ function methodSummary(methodLog = []) {
128
+ const summary = {};
129
+ for (const entry of methodLog || []) {
130
+ summary[entry.method] = (summary[entry.method] || 0) + 1;
131
+ }
132
+ return summary;
133
+ }
134
+
135
+ function clonePlain(value, fallback = null) {
136
+ try {
137
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
138
+ } catch {
139
+ return fallback;
140
+ }
141
+ }
142
+
143
+ function plainRecord(value) {
144
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
145
+ }
146
+
147
+ function nonEmptyRecord(value) {
148
+ const record = plainRecord(value);
149
+ return Object.keys(record).length ? record : null;
150
+ }
151
+
152
+ function normalizeRunId(runId) {
153
+ const normalized = normalizeText(runId);
154
+ if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
155
+ return normalized;
156
+ }
157
+
158
+ function getRecommendRunArtifacts(runId) {
159
+ const normalized = normalizeRunId(runId);
160
+ if (!normalized) return null;
161
+ const runsDir = getRunsDir();
162
+ const outputDir = resolveBossConfiguredOutputDir("", runsDir);
163
+ return {
164
+ runs_dir: runsDir,
165
+ output_dir: outputDir,
166
+ run_state_path: path.join(runsDir, `${normalized}.json`),
167
+ checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
168
+ worker_stdout_path: path.join(runsDir, `${normalized}.worker.stdout.log`),
169
+ worker_stderr_path: path.join(runsDir, `${normalized}.worker.stderr.log`),
170
+ output_csv: path.join(outputDir, `${normalized}.results.csv`),
171
+ report_json: path.join(outputDir, `${normalized}.report.json`)
172
+ };
173
+ }
174
+
175
+ function ensureDirectory(dirPath) {
176
+ fs.mkdirSync(dirPath, { recursive: true });
177
+ }
178
+
179
+ function writeJsonAtomic(filePath, payload) {
180
+ ensureDirectory(path.dirname(filePath));
181
+ const tempPath = `${filePath}.tmp`;
182
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
183
+ fs.renameSync(tempPath, filePath);
184
+ }
185
+
186
+ function readJsonFile(filePath) {
187
+ try {
188
+ if (!fs.existsSync(filePath)) return null;
189
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
190
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function recommendSearchParamsForCsv(searchParams = {}) {
197
+ return {
198
+ school_tag: Object.prototype.hasOwnProperty.call(searchParams, "school_tag") ? searchParams.school_tag : "不限",
199
+ degree: Object.prototype.hasOwnProperty.call(searchParams, "degree") ? searchParams.degree : "不限",
200
+ gender: Object.prototype.hasOwnProperty.call(searchParams, "gender") ? searchParams.gender : "不限",
201
+ recent_not_view: Object.prototype.hasOwnProperty.call(searchParams, "recent_not_view") ? searchParams.recent_not_view : "不限"
202
+ };
203
+ }
204
+
205
+ function getSnapshotRequestContext(snapshot = {}) {
206
+ const context = plainRecord(snapshot?.context);
207
+ const shared = plainRecord(context.shared_run_context);
208
+ return {
209
+ context,
210
+ confirmation: nonEmptyRecord(context.confirmation) || plainRecord(shared.confirmation),
211
+ overrides: nonEmptyRecord(context.overrides) || plainRecord(shared.overrides),
212
+ followUp: context.follow_up ?? shared.follow_up ?? null,
213
+ shared
214
+ };
215
+ }
216
+
217
+ function selectedRecommendJobForCsv(meta = {}, snapshot = {}) {
218
+ const { confirmation, overrides, shared } = getSnapshotRequestContext(snapshot);
219
+ const value = normalizeText(
220
+ meta.args?.confirmation?.job_value
221
+ || meta.normalized?.job
222
+ || meta.args?.overrides?.job
223
+ || confirmation.job_value
224
+ || overrides.job
225
+ || shared.confirmation?.job_value
226
+ || shared.overrides?.job
227
+ || shared.job_label
228
+ || ""
229
+ );
230
+ return {
231
+ value,
232
+ title: value,
233
+ label: value
234
+ };
235
+ }
236
+
237
+ function buildRecommendCsvInputRows(snapshot = {}, meta = {}) {
238
+ const { context, confirmation, overrides, followUp, shared } = getSnapshotRequestContext(snapshot);
239
+ const searchParams = recommendSearchParamsForCsv(meta.parsed?.searchParams || {
240
+ school_tag: overrides.school_tag ?? confirmation.school_tag_value,
241
+ degree: overrides.degree ?? confirmation.degree_value,
242
+ gender: overrides.gender ?? confirmation.gender_value,
243
+ recent_not_view: overrides.recent_not_view ?? confirmation.recent_not_view_value
244
+ });
245
+ const parsedScreenParams = meta.parsed?.screenParams || {};
246
+ const screenParams = {
247
+ criteria: parsedScreenParams.criteria || meta.normalized?.criteria || overrides.criteria || "",
248
+ target_count: parsedScreenParams.target_count || snapshot.progress?.target_count || meta.normalized?.targetCount || overrides.target_count || confirmation.target_count_value || shared.max_candidates || "",
249
+ post_action: parsedScreenParams.post_action || overrides.post_action || confirmation.post_action_value || shared.post_action || "none",
250
+ max_greet_count: parsedScreenParams.max_greet_count ?? overrides.max_greet_count ?? confirmation.max_greet_count_value ?? shared.max_greet_count ?? ""
251
+ };
252
+ return buildLegacyScreenInputRows({
253
+ instruction: meta.args?.instruction || context.instruction || shared.instruction || "",
254
+ selectedPage: "recommend",
255
+ selectedJob: selectedRecommendJobForCsv(meta, snapshot),
256
+ userSearchParams: cloneReportInput(searchParams, {}),
257
+ effectiveSearchParams: cloneReportInput(searchParams, {}),
258
+ screenParams,
259
+ followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || followUp || overrides.follow_up || null
260
+ });
261
+ }
262
+
263
+ function writeRecommendLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
264
+ writeLegacyScreenCsv(filePath, {
265
+ inputRows: buildRecommendCsvInputRows(snapshot, meta),
266
+ results: rows
267
+ });
268
+ }
269
+
270
+ function readRecommendRunState(runId) {
271
+ const artifacts = getRecommendRunArtifacts(runId);
272
+ if (!artifacts) return null;
273
+ return readJsonFile(artifacts.run_state_path);
274
+ }
275
+
276
+ function isProcessAlive(pid) {
277
+ if (!Number.isInteger(pid) || pid <= 0) return false;
278
+ try {
279
+ process.kill(pid, 0);
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ function getRecommendRunMeta(runId) {
287
+ return recommendRunMeta.get(runId) || {};
288
+ }
289
+
290
+ function toIsoOrNull(value) {
291
+ const normalized = normalizeText(value);
292
+ return normalized || null;
293
+ }
294
+
295
+ function secondsBetween(startedAt, endedAt) {
296
+ const startMs = Date.parse(startedAt || "");
297
+ const endMs = Date.parse(endedAt || "") || Date.now();
298
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
299
+ return Math.max(1, Math.round((endMs - startMs) / 1000));
300
+ }
301
+
302
+ function normalizeLegacyProgress(progress = {}, summary = null) {
303
+ const processed = Number.isInteger(progress.processed)
304
+ ? progress.processed
305
+ : Number.isInteger(summary?.processed)
306
+ ? summary.processed
307
+ : 0;
308
+ const screened = Number.isInteger(progress.screened)
309
+ ? progress.screened
310
+ : Number.isInteger(summary?.screened)
311
+ ? summary.screened
312
+ : processed;
313
+ const passed = Number.isInteger(progress.passed)
314
+ ? progress.passed
315
+ : Number.isInteger(summary?.passed)
316
+ ? summary.passed
317
+ : 0;
318
+ return {
319
+ ...progress,
320
+ processed,
321
+ inspected: processed,
322
+ screened,
323
+ passed,
324
+ skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
325
+ greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0,
326
+ post_action_clicked: Number.isInteger(progress.post_action_clicked) ? progress.post_action_clicked : 0
327
+ };
328
+ }
329
+
330
+ function completionReason(status) {
331
+ if (status === RUN_STATUS_COMPLETED) return "completed";
332
+ if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
333
+ if (status === RUN_STATUS_FAILED) return "failed";
334
+ if (status === RUN_STATUS_PAUSED) return "paused";
335
+ return null;
336
+ }
337
+
338
+ function normalizeErrorText(error = {}) {
339
+ return normalizeText([
340
+ error?.code || "",
341
+ error?.message || error || ""
342
+ ].join(" "));
343
+ }
344
+
345
+ function classifyRecommendRecovery(error = {}) {
346
+ const text = normalizeErrorText(error);
347
+ if (!text) return null;
348
+ if (/BOSS_LOGIN_REQUIRED/i.test(text)) return "login_required";
349
+ if (/Could not find node with given id|No node with given id|Node is detached|Cannot find node|DETAIL_STALE_NODE|IMAGE_CAPTURE_STALE_NODE/i.test(text)) {
350
+ return "transient_stale_dom";
351
+ }
352
+ if (/IMAGE_CAPTURE_TIMEOUT|IMAGE_CAPTURE_TOTAL_TIMEOUT|Image fallback capture timed out/i.test(text)) {
353
+ return "transient_image_capture";
354
+ }
355
+ if (/(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i.test(text)) {
356
+ return "transient_network_or_llm";
357
+ }
358
+ return null;
359
+ }
360
+
361
+ function buildConstrainedAgentRecovery(snapshot = {}, meta = {}, artifacts = null) {
362
+ const error = snapshot?.error || snapshot?.result?.error || null;
363
+ const classification = classifyRecommendRecovery(error);
364
+ if (!classification) return null;
365
+ const canRestartSameRequest = classification !== "login_required";
366
+ return {
367
+ policy_version: 1,
368
+ classification,
369
+ safe_for_outer_ai_agent: true,
370
+ recommended_action: canRestartSameRequest
371
+ ? "restart_same_recommend_request_only"
372
+ : "ask_user_to_login_then_retry_same_recommend_request",
373
+ package_requirement: "@reconcrap/boss-recommend-mcp@>=2.0.30",
374
+ run_id: snapshot?.runId || snapshot?.run_id || null,
375
+ retryable: true,
376
+ same_request_sources: {
377
+ instruction: "run.context.instruction",
378
+ confirmation: "run.context.confirmation",
379
+ overrides: "run.context.overrides",
380
+ follow_up: "run.context.follow_up"
381
+ },
382
+ constraints: [
383
+ "Do not change instruction, criteria, filters, job, page_scope, target_count, post_action, or max_greet_count.",
384
+ "Do not switch to search/recruit/chat and do not add follow_up.chat.",
385
+ "Do not summarize, translate, or rewrite criteria.",
386
+ "Do not ask the user to reconfirm business choices unless Boss login is required or the stored context is missing.",
387
+ "Use the same Chrome debug port and recommend page route."
388
+ ],
389
+ artifacts: artifacts ? {
390
+ run_state_path: artifacts.run_state_path || null,
391
+ checkpoint_path: artifacts.checkpoint_path || null,
392
+ report_json: artifacts.report_json || null,
393
+ output_csv: artifacts.output_csv || null
394
+ } : null
395
+ };
396
+ }
397
+
398
+ function ensureRecommendRunArtifacts(snapshot) {
399
+ const artifacts = getRecommendRunArtifacts(snapshot?.runId || snapshot?.run_id);
400
+ if (!artifacts) return null;
401
+
402
+ const meta = getRecommendRunMeta(snapshot?.runId || snapshot?.run_id);
403
+ const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
404
+ ? snapshot.checkpoint
405
+ : {};
406
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
407
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
408
+
409
+ const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
410
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
411
+ const artifactSummary = summary || (checkpointResults.length ? {
412
+ domain: "recommend",
413
+ partial: true,
414
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
415
+ results: checkpointResults
416
+ } : null);
417
+ if (artifactSummary) {
418
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
419
+ writeRecommendLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
420
+ writeJsonAtomic(artifacts.report_json, {
421
+ run_id: snapshot.runId || snapshot.run_id,
422
+ status: snapshot.status || snapshot.state,
423
+ phase: snapshot.phase || snapshot.stage,
424
+ progress: snapshot.progress || {},
425
+ context: snapshot.context || {},
426
+ checkpoint,
427
+ error: snapshot.error || null,
428
+ last_message: snapshot.error?.message || snapshot.phase || snapshot.stage || null,
429
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
430
+ summary: artifactSummary,
431
+ generated_at: new Date().toISOString()
432
+ });
433
+ if (meta) {
434
+ meta.outputCsvPath = artifacts.output_csv;
435
+ meta.reportJsonPath = artifacts.report_json;
436
+ }
437
+ }
438
+
439
+ return artifacts;
440
+ }
441
+
442
+ function persistRecommendCheckpointSnapshot(normalized) {
443
+ const artifacts = getRecommendRunArtifacts(normalized?.run_id || normalized?.runId);
444
+ if (!artifacts) return;
445
+ const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
446
+ ? normalized.checkpoint
447
+ : {};
448
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
449
+ const meta = getRecommendRunMeta(normalized?.run_id || normalized?.runId);
450
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
451
+ }
452
+
453
+ function buildLegacyRecommendResult(snapshot) {
454
+ if (!snapshot) return null;
455
+ const artifacts = ensureRecommendRunArtifacts(snapshot);
456
+ const meta = getRecommendRunMeta(snapshot.runId);
457
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
458
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
459
+ const resultRows = Array.isArray(summary?.results)
460
+ ? summary.results
461
+ : Array.isArray(checkpoint.results)
462
+ ? checkpoint.results
463
+ : [];
464
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
465
+ const targetCount = Number.isInteger(progress.target_count)
466
+ ? progress.target_count
467
+ : Number.isInteger(snapshot.context?.max_candidates)
468
+ ? snapshot.context.max_candidates
469
+ : meta.parsed?.screenParams?.target_count || null;
470
+ return {
471
+ status: snapshot.status === RUN_STATUS_COMPLETED
472
+ ? "COMPLETED"
473
+ : snapshot.status === RUN_STATUS_CANCELED
474
+ ? "CANCELED"
475
+ : snapshot.status === RUN_STATUS_PAUSED
476
+ ? "PAUSED"
477
+ : snapshot.status === RUN_STATUS_FAILED
478
+ ? "FAILED"
479
+ : snapshot.status,
480
+ run_id: snapshot.runId,
481
+ completion_reason: completionReason(snapshot.status),
482
+ requested_count: targetCount,
483
+ processed_count: progress.processed,
484
+ inspected_count: progress.processed,
485
+ screened_count: progress.screened,
486
+ passed_count: progress.passed,
487
+ skipped_count: progress.skipped,
488
+ detail_opened: progress.detail_opened || summary?.detail_opened || 0,
489
+ greet_count: progress.greet_count || 0,
490
+ post_action_clicked: progress.post_action_clicked || summary?.post_action_clicked || 0,
491
+ output_csv: artifacts?.output_csv || meta.outputCsvPath || null,
492
+ report_json: artifacts?.report_json || meta.reportJsonPath || null,
493
+ checkpoint_path: artifacts?.checkpoint_path || meta.checkpointPath || null,
494
+ started_at: snapshot.startedAt,
495
+ completed_at: snapshot.completedAt || null,
496
+ duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt),
497
+ selected_job: {
498
+ title: meta.normalized?.job || meta.args?.confirmation?.job_value || meta.args?.overrides?.job || ""
499
+ },
500
+ selected_page_scope: summary?.page_scope || {
501
+ requested_scope: meta.normalized?.pageScope || meta.parsed?.page_scope || "recommend",
502
+ effective_scope: meta.normalized?.pageScope || meta.parsed?.page_scope || "recommend"
503
+ },
504
+ search_params: clonePlain(meta.parsed?.searchParams || {}, {}),
505
+ screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
506
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
507
+ error: snapshot.error || null,
508
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
509
+ results: resultRows
510
+ };
511
+ }
512
+
513
+ function normalizeRunSnapshot(snapshot) {
514
+ if (!snapshot) return null;
515
+ const meta = getRecommendRunMeta(snapshot.runId);
516
+ const artifacts = getRecommendRunArtifacts(snapshot.runId);
517
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
518
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
519
+ const legacyResult = (
520
+ TERMINAL_STATUSES.has(snapshot.status)
521
+ || snapshot.status === RUN_STATUS_PAUSED
522
+ ) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
523
+ const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
524
+ const snapshotContext = plainRecord(snapshot.context);
525
+ const metaArgs = plainRecord(meta.args);
526
+ const oldContext = {
527
+ workspace_root: meta.workspaceRoot || snapshotContext.workspace_root || null,
528
+ instruction: metaArgs.instruction || snapshotContext.instruction || "",
529
+ confirmation: clonePlain(metaArgs.confirmation ?? snapshotContext.confirmation ?? {}, {}),
530
+ overrides: clonePlain(metaArgs.overrides ?? snapshotContext.overrides ?? {}, {}),
531
+ follow_up: clonePlain(metaArgs.follow_up ?? snapshotContext.follow_up ?? null, null),
532
+ target_count_semantics: TARGET_COUNT_SEMANTICS
533
+ };
534
+ return {
535
+ ...snapshot,
536
+ progress,
537
+ run_id: snapshot.runId,
538
+ mode: RUN_MODE_ASYNC,
539
+ state: snapshot.status,
540
+ stage: snapshot.phase,
541
+ started_at: snapshot.startedAt,
542
+ updated_at: snapshot.updatedAt,
543
+ completed_at: toIsoOrNull(snapshot.completedAt),
544
+ heartbeat_at: snapshot.updatedAt,
545
+ pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid || null,
546
+ last_message: snapshot.error?.message || snapshot.phase || null,
547
+ context: {
548
+ ...snapshotContext,
549
+ ...oldContext,
550
+ shared_run_context: snapshotContext
551
+ },
552
+ control: {
553
+ pause_requested: snapshot.status === RUN_STATUS_PAUSED,
554
+ pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
555
+ pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recommend_pipeline_run" : null,
556
+ cancel_requested: snapshot.status === RUN_STATUS_CANCELING
557
+ },
558
+ resume: {
559
+ checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
560
+ pause_control_path: artifacts?.run_state_path || null,
561
+ output_csv: legacyResult?.output_csv || null,
562
+ worker_stdout_path: artifacts?.worker_stdout_path || null,
563
+ worker_stderr_path: artifacts?.worker_stderr_path || null,
564
+ resume_count: meta.resumeCount || 0,
565
+ last_resumed_at: meta.lastResumedAt || null,
566
+ last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
567
+ },
568
+ recovery,
569
+ result: legacyResult,
570
+ artifacts
571
+ };
572
+ }
573
+
574
+ function mergePersistedControlRequest(normalized, existing) {
575
+ const control = {
576
+ ...(normalized?.control || {})
577
+ };
578
+ if (!normalized || TERMINAL_STATUSES.has(normalized.state)) return control;
579
+ const existingControl = plainRecord(existing?.control);
580
+ if (existingControl.cancel_requested === true) {
581
+ return {
582
+ ...control,
583
+ pause_requested: true,
584
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
585
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_recommend_pipeline_run",
586
+ cancel_requested: true
587
+ };
588
+ }
589
+ if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
590
+ return {
591
+ ...control,
592
+ pause_requested: true,
593
+ pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
594
+ pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_recommend_pipeline_run"
595
+ };
596
+ }
597
+ if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
598
+ return {
599
+ ...control,
600
+ pause_requested: false,
601
+ pause_requested_at: null,
602
+ pause_requested_by: null,
603
+ cancel_requested: false
604
+ };
605
+ }
606
+ return control;
607
+ }
608
+
609
+ function persistRecommendRunSnapshot(snapshot, {
610
+ persistActiveCheckpoint = false
611
+ } = {}) {
612
+ const normalized = normalizeRunSnapshot(snapshot);
613
+ if (!normalized?.run_id) return normalized;
614
+ const artifacts = getRecommendRunArtifacts(normalized.run_id);
615
+ if (!artifacts) return normalized;
616
+ const existing = readJsonFile(artifacts.run_state_path);
617
+ normalized.control = mergePersistedControlRequest(normalized, existing);
618
+ if (persistActiveCheckpoint) {
619
+ persistRecommendCheckpointSnapshot(normalized);
620
+ }
621
+ const payload = {
622
+ run_id: normalized.run_id,
623
+ mode: normalized.mode,
624
+ state: normalized.state,
625
+ status: normalized.status,
626
+ stage: normalized.stage,
627
+ started_at: normalized.started_at,
628
+ updated_at: normalized.updated_at,
629
+ heartbeat_at: normalized.heartbeat_at,
630
+ completed_at: normalized.completed_at,
631
+ pid: normalized.pid,
632
+ progress: normalized.progress,
633
+ last_message: normalized.last_message,
634
+ context: normalized.context,
635
+ control: normalized.control,
636
+ resume: normalized.resume,
637
+ error: normalized.error,
638
+ recovery: normalized.recovery,
639
+ result: normalized.result,
640
+ summary: normalized.summary,
641
+ artifacts: normalized.artifacts
642
+ };
643
+ writeJsonAtomic(artifacts.run_state_path, payload);
644
+ return normalized;
645
+ }
646
+
647
+ function reconcilePersistedRecommendRunIfNeeded(persisted) {
648
+ if (!persisted || typeof persisted !== "object") return persisted;
649
+ const persistedState = normalizeText(persisted.state || persisted.status);
650
+ if (TERMINAL_STATUSES.has(persistedState)) return persisted;
651
+ if (isProcessAlive(persisted.pid)) return persisted;
652
+
653
+ const runId = normalizeRunId(persisted.run_id || persisted.runId);
654
+ const artifacts = getRecommendRunArtifacts(runId);
655
+ const checkpoint = artifacts?.checkpoint_path ? readJsonFile(artifacts.checkpoint_path) : null;
656
+ const now = new Date().toISOString();
657
+ const error = {
658
+ code: "RUN_PROCESS_EXITED",
659
+ message: `检测到推荐任务进程已退出(pid=${persisted.pid || "unknown"}),已自动标记为失败。`,
660
+ retryable: true
661
+ };
662
+ return persistRecommendRunSnapshot({
663
+ runId,
664
+ name: persisted.name || runId,
665
+ status: RUN_STATUS_FAILED,
666
+ phase: persisted.stage || persisted.phase || "recommend:orphaned",
667
+ progress: persisted.progress || {},
668
+ context: persisted.context || {},
669
+ checkpoint: checkpoint || persisted.checkpoint || {},
670
+ startedAt: persisted.started_at || persisted.startedAt || now,
671
+ updatedAt: now,
672
+ completedAt: now,
673
+ pid: Number.isInteger(persisted.pid) && persisted.pid > 0 ? persisted.pid : null,
674
+ error,
675
+ summary: persisted.summary || null
676
+ });
677
+ }
678
+
679
+ function persistRecommendLifecycleSnapshot(snapshot, event = {}) {
680
+ return persistRecommendRunSnapshot(snapshot, {
681
+ persistActiveCheckpoint: event?.type === "checkpoint"
682
+ });
683
+ }
684
+
685
+ function attachMethodEvidence(payload, runId) {
686
+ const meta = getRecommendRunMeta(runId);
687
+ assertNoForbiddenCdpCalls(meta.methodLog || []);
688
+ return {
689
+ ...payload,
690
+ runtime_evaluate_used: false,
691
+ method_summary: methodSummary(meta.methodLog || []),
692
+ method_log: meta.methodLog || [],
693
+ chrome: meta.chrome || null
694
+ };
695
+ }
696
+
697
+ function compactRecommendJobListOption(option, index) {
698
+ const label = normalizeText(option?.label);
699
+ const name = normalizeText(option?.label_without_salary || label);
700
+ return {
701
+ index,
702
+ name,
703
+ label,
704
+ label_without_salary: name,
705
+ current: Boolean(option?.current),
706
+ visible: Boolean(option?.visible)
707
+ };
708
+ }
709
+
710
+ async function readRecommendJobOptionsFromSession(session) {
711
+ const client = session?.client;
712
+ if (!client) throw new Error("Recommend Chrome session is missing a CDP client");
713
+ const rootState = await getRecommendRoots(client);
714
+ const frameNodeId = rootState?.iframe?.documentNodeId;
715
+ if (!frameNodeId) throw new Error("recommendFrame iframe document was not found");
716
+
717
+ let options = [];
718
+ try {
719
+ options = await listRecommendJobOptions(client, frameNodeId, {
720
+ openDropdown: true
721
+ });
722
+ } finally {
723
+ await closeRecommendJobDropdown(client).catch(() => {});
724
+ }
725
+
726
+ const compacted = [];
727
+ const seen = new Set();
728
+ for (const option of options) {
729
+ const compact = compactRecommendJobListOption(option, compacted.length);
730
+ if (!compact.name && !compact.label) continue;
731
+ const key = `${compact.name}\n${compact.label}`;
732
+ if (seen.has(key)) continue;
733
+ seen.add(key);
734
+ compacted.push(compact);
735
+ }
736
+
737
+ return {
738
+ source: "recommend_job_dropdown",
739
+ selector: "recommend job selection dropdown",
740
+ job_options: compacted,
741
+ selected_job: compacted.find((option) => option.current) || null
742
+ };
743
+ }
744
+
745
+ export async function listRecommendJobsTool({ workspaceRoot = "", args = {} } = {}) {
746
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
747
+ const host = normalizeText(args.host) || DEFAULT_RECOMMEND_HOST;
748
+ const port = parsePositiveInteger(
749
+ args.port,
750
+ configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECOMMEND_PORT
751
+ );
752
+ const targetUrlIncludes = normalizeText(args.target_url_includes) || RECOMMEND_TARGET_URL;
753
+ const allowNavigate = args.allow_navigate !== false;
754
+ const slowLive = args.slow_live === true;
755
+ let session;
756
+
757
+ try {
758
+ session = await recommendConnectorImpl({
759
+ host,
760
+ port,
761
+ targetUrlIncludes,
762
+ allowNavigate,
763
+ slowLive
764
+ });
765
+
766
+ const jobs = await recommendJobReaderImpl(session, {
767
+ workspaceRoot: normalizeText(workspaceRoot) || process.cwd(),
768
+ args: clonePlain(args, {}),
769
+ normalized: {
770
+ host,
771
+ port,
772
+ targetUrlIncludes,
773
+ allowNavigate,
774
+ slowLive
775
+ }
776
+ });
777
+ const jobOptions = Array.isArray(jobs?.job_options) ? jobs.job_options : [];
778
+ assertNoForbiddenCdpCalls(session.methodLog || []);
779
+ return {
780
+ status: "OK",
781
+ stage: "recommend_job_list",
782
+ cdp_only: true,
783
+ runtime_evaluate_used: false,
784
+ page_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
785
+ job_count: jobOptions.length,
786
+ job_names: jobOptions.map((option) => option.name || option.label).filter(Boolean),
787
+ job_full_labels: jobOptions.map((option) => option.label || option.name).filter(Boolean),
788
+ job_options: jobOptions,
789
+ selected_job: jobs?.selected_job || jobOptions.find((option) => option.current) || null,
790
+ source: jobs?.source || "recommend_job_dropdown",
791
+ selector: jobs?.selector || "",
792
+ message: "已通过 CDP-only 从推荐页岗位下拉框读取可用岗位。Cron/一次性任务里的 job 参数优先使用 job_names 中的完整岗位名。",
793
+ chrome: {
794
+ host,
795
+ port,
796
+ target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
797
+ target_id: session.target?.id || null,
798
+ auto_launch: session.chrome || null
799
+ },
800
+ method_summary: methodSummary(session.methodLog || []),
801
+ method_log: session.methodLog || []
802
+ };
803
+ } catch (error) {
804
+ const methodLog = session?.methodLog || [];
805
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
806
+ return {
807
+ status: "FAILED",
808
+ stage: "recommend_job_list",
809
+ cdp_only: true,
810
+ runtime_evaluate_used: methodLog.some((entry) => String(entry?.method || entry).startsWith("Runtime.")),
811
+ error: {
812
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "RECOMMEND_JOB_LIST_FAILED",
813
+ message: error?.message || "Failed to read recommend job list",
814
+ requires_login: Boolean(error?.requires_login),
815
+ login_url: error?.login_url || null,
816
+ login_detection: error?.login_detection || null,
817
+ current_url: error?.current_url || null,
818
+ target_url: error?.target_url || RECOMMEND_TARGET_URL,
819
+ chrome: error?.chrome || null,
820
+ retryable: true
821
+ },
822
+ chrome: {
823
+ host,
824
+ port,
825
+ target_url: targetUrlIncludes,
826
+ auto_launch: error?.chrome || session?.chrome || null
827
+ },
828
+ method_summary: methodSummary(methodLog),
829
+ method_log: methodLog
830
+ };
831
+ } finally {
832
+ if (session) {
833
+ try {
834
+ await session.close?.();
835
+ } catch {
836
+ // Best-effort cleanup after a read-only helper.
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ function compactHealth(check) {
843
+ if (!check) return null;
844
+ return {
845
+ status: check.status,
846
+ summary: check.summary,
847
+ drift_report: check.drift_report,
848
+ probes: (check.probes || []).map((probe) => ({
849
+ id: probe.id,
850
+ type: probe.type,
851
+ status: probe.status,
852
+ count: probe.count,
853
+ required: probe.required
854
+ }))
855
+ };
856
+ }
857
+
858
+ async function waitForHealthyRecommend(client, config, {
859
+ timeoutMs = 90000,
860
+ intervalMs = 1000
861
+ } = {}) {
862
+ const started = Date.now();
863
+ let lastCheck = null;
864
+ while (Date.now() - started <= timeoutMs) {
865
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
866
+ if (loginDetection?.requires_login) {
867
+ return {
868
+ status: "login_required",
869
+ summary: "Boss login is required",
870
+ loginDetection
871
+ };
872
+ }
873
+ const roots = await resolveRecommendSelfHealRoots(client, config);
874
+ lastCheck = await runSelfHealCheck({
875
+ client,
876
+ domain: "recommend",
877
+ roots: roots.roots,
878
+ selectorProbes: config.selectorProbes,
879
+ accessibilityProbes: config.accessibilityProbes,
880
+ viewportProbes: config.viewportProbes
881
+ });
882
+ if (lastCheck.status === HEALTH_STATUS.HEALTHY) return lastCheck;
883
+ await sleep(intervalMs);
884
+ }
885
+ return lastCheck;
886
+ }
887
+
888
+ function shouldNavigateToRecommend(url) {
889
+ return !String(url || "").includes("/web/chat/recommend");
890
+ }
891
+
892
+ async function connectRecommendChromeSession({
893
+ host = DEFAULT_RECOMMEND_HOST,
894
+ port = DEFAULT_RECOMMEND_PORT,
895
+ targetUrlIncludes = RECOMMEND_TARGET_URL,
896
+ allowNavigate = true,
897
+ slowLive = false
898
+ } = {}) {
899
+ const session = await connectToChromeTargetOrOpen({
900
+ host,
901
+ port,
902
+ targetUrlIncludes,
903
+ targetUrl: RECOMMEND_TARGET_URL,
904
+ allowNavigate,
905
+ slowLive,
906
+ fallbackTargetPredicate: (target) => (
907
+ target?.type === "page"
908
+ && String(target?.url || "").includes("zhipin.com")
909
+ )
910
+ });
911
+
912
+ const { client, target } = session;
913
+ await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
914
+ if (typeof client?.Network?.setCacheDisabled === "function") {
915
+ await client.Network.setCacheDisabled({ cacheDisabled: true });
916
+ }
917
+ await bringPageToFront(client);
918
+
919
+ const targetUrl = String(target?.url || "");
920
+ let navigation = {
921
+ navigated: false,
922
+ url: targetUrl
923
+ };
924
+ if (allowNavigate && shouldNavigateToRecommend(targetUrl)) {
925
+ await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
926
+ const settleMs = slowLive ? 12000 : 5000;
927
+ const waited = await waitForMainFrameUrl(
928
+ client,
929
+ (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
930
+ { timeoutMs: settleMs, intervalMs: 500 }
931
+ );
932
+ navigation = {
933
+ navigated: true,
934
+ url: RECOMMEND_TARGET_URL,
935
+ settle_ms: settleMs,
936
+ observed_url: waited.url || null,
937
+ observed_url_ok: waited.ok
938
+ };
939
+ }
940
+ let currentUrl = await getMainFrameUrl(client).catch(() => navigation.url || targetUrl);
941
+ if (allowNavigate && shouldNavigateToRecommend(currentUrl) && !isBossLoginUrl(currentUrl)) {
942
+ await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
943
+ const settleMs = slowLive ? 12000 : 5000;
944
+ const waited = await waitForMainFrameUrl(
945
+ client,
946
+ (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
947
+ { timeoutMs: settleMs, intervalMs: 500 }
948
+ );
949
+ navigation = {
950
+ navigated: true,
951
+ url: RECOMMEND_TARGET_URL,
952
+ settle_ms: settleMs,
953
+ observed_url: waited.url || null,
954
+ observed_url_ok: waited.ok,
955
+ reason: "observed_url_mismatch"
956
+ };
957
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
958
+ }
959
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
960
+ requires_login: isBossLoginUrl(currentUrl),
961
+ reason: "login_detection_failed",
962
+ current_url: currentUrl
963
+ }));
964
+ if (loginDetection.requires_login) {
965
+ await session.close?.();
966
+ throw createBossLoginRequiredError({
967
+ domain: "recommend",
968
+ currentUrl: loginDetection.current_url || currentUrl,
969
+ targetUrl: RECOMMEND_TARGET_URL,
970
+ loginDetection,
971
+ chrome: session.chrome || null
972
+ });
973
+ }
974
+ if (shouldNavigateToRecommend(currentUrl)) {
975
+ await session.close?.();
976
+ throw new Error(`Boss recommend page did not navigate to ${RECOMMEND_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
977
+ }
978
+
979
+ const selfHealConfig = buildRecommendSelfHealConfig();
980
+ const health = await waitForHealthyRecommend(client, selfHealConfig, {
981
+ timeoutMs: slowLive ? 180000 : 90000,
982
+ intervalMs: slowLive ? 1200 : 800
983
+ });
984
+ if (health?.loginDetection?.requires_login) {
985
+ await session.close?.();
986
+ throw createBossLoginRequiredError({
987
+ domain: "recommend",
988
+ currentUrl: health.loginDetection.current_url || currentUrl,
989
+ targetUrl: RECOMMEND_TARGET_URL,
990
+ loginDetection: health.loginDetection,
991
+ chrome: session.chrome || null
992
+ });
993
+ }
994
+ if (!health || health.status !== HEALTH_STATUS.HEALTHY) {
995
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
996
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
997
+ requires_login: isBossLoginUrl(latestUrl),
998
+ reason: "login_detection_failed",
999
+ current_url: latestUrl
1000
+ }));
1001
+ if (latestLoginDetection.requires_login) {
1002
+ await session.close?.();
1003
+ throw createBossLoginRequiredError({
1004
+ domain: "recommend",
1005
+ currentUrl: latestLoginDetection.current_url || latestUrl,
1006
+ targetUrl: RECOMMEND_TARGET_URL,
1007
+ loginDetection: latestLoginDetection,
1008
+ chrome: session.chrome || null
1009
+ });
1010
+ }
1011
+ throw new Error(`Boss recommend page is not healthy: ${health?.status || "missing"}`);
1012
+ }
1013
+
1014
+ return {
1015
+ ...session,
1016
+ navigation,
1017
+ health
1018
+ };
1019
+ }
1020
+
1021
+ function parseRecommendPipelineRequest(args = {}) {
1022
+ return parseRecommendInstruction({
1023
+ instruction: args.instruction,
1024
+ confirmation: args.confirmation,
1025
+ overrides: args.overrides
1026
+ });
1027
+ }
1028
+
1029
+ function buildRequiredConfirmations(parsed, args = {}) {
1030
+ const required = [];
1031
+ if (parsed.needs_page_confirmation) required.push("page_scope");
1032
+ if (parsed.needs_filters_confirmation) required.push("filters");
1033
+ if (parsed.needs_school_tag_confirmation) required.push("school_tag");
1034
+ if (parsed.needs_degree_confirmation) required.push("degree");
1035
+ if (parsed.needs_gender_confirmation) required.push("gender");
1036
+ if (parsed.needs_recent_not_view_confirmation) required.push("recent_not_view");
1037
+ if (parsed.needs_criteria_confirmation) required.push("criteria");
1038
+ if (parsed.needs_target_count_confirmation) required.push("target_count");
1039
+ if (parsed.needs_post_action_confirmation) required.push("post_action");
1040
+ if (parsed.needs_max_greet_count_confirmation) required.push("max_greet_count");
1041
+ if ((parsed.suspicious_fields || []).length) required.push("suspicious_fields");
1042
+
1043
+ const confirmation = args.confirmation || {};
1044
+ const jobValue = normalizeText(confirmation.job_value || args.overrides?.job || "");
1045
+ if (confirmation.job_confirmed !== true || !jobValue) required.push("job");
1046
+ if (confirmation.final_confirmed !== true) required.push("final_review");
1047
+ return Array.from(new Set(required));
1048
+ }
1049
+
1050
+ function buildJobPendingQuestion(args = {}) {
1051
+ const value = normalizeText(args.confirmation?.job_value || args.overrides?.job || "");
1052
+ return {
1053
+ field: "job",
1054
+ question: "请确认推荐页岗位。CDP-only rewrite 会先切换到该岗位,再按所选页面范围执行筛选。",
1055
+ value: value || null
1056
+ };
1057
+ }
1058
+
1059
+ function buildFinalReviewQuestion(parsed) {
1060
+ return {
1061
+ field: "final_review",
1062
+ question: "请最终确认本次推荐页筛选参数无误,并明确 final_confirmed=true 后再启动。",
1063
+ value: {
1064
+ page_scope: parsed.page_scope,
1065
+ search_params: parsed.searchParams,
1066
+ screen_params: parsed.screenParams
1067
+ }
1068
+ };
1069
+ }
1070
+
1071
+ function buildNeedInputResponse(parsed) {
1072
+ return {
1073
+ status: "NEED_INPUT",
1074
+ missing_fields: parsed.missing_fields,
1075
+ required_confirmations: buildRequiredConfirmations(parsed),
1076
+ search_params: parsed.searchParams,
1077
+ screen_params: parsed.screenParams,
1078
+ pending_questions: parsed.pending_questions,
1079
+ review: parsed.review,
1080
+ error: {
1081
+ code: "MISSING_REQUIRED_FIELDS",
1082
+ message: "缺少必要字段。请补齐推荐页 criteria 等必填字段后再启动 CDP-only recommend run。",
1083
+ retryable: true
1084
+ }
1085
+ };
1086
+ }
1087
+
1088
+ function buildNeedConfirmationResponse(parsed, args, requiredConfirmations) {
1089
+ const pending = [...(parsed.pending_questions || [])];
1090
+ if (requiredConfirmations.includes("job") && !pending.some((item) => item.field === "job")) {
1091
+ pending.push(buildJobPendingQuestion(args));
1092
+ }
1093
+ if (requiredConfirmations.includes("final_review") && !pending.some((item) => item.field === "final_review")) {
1094
+ pending.push(buildFinalReviewQuestion(parsed));
1095
+ }
1096
+ return {
1097
+ status: "NEED_CONFIRMATION",
1098
+ required_confirmations: requiredConfirmations,
1099
+ page_scope: parsed.page_scope,
1100
+ search_params: parsed.searchParams,
1101
+ screen_params: parsed.screenParams,
1102
+ pending_questions: pending,
1103
+ review: {
1104
+ ...(parsed.review || {}),
1105
+ required_confirmations: requiredConfirmations
1106
+ }
1107
+ };
1108
+ }
1109
+
1110
+ function evaluateRecommendPipelineGate(parsed, args = {}) {
1111
+ if (parsed.missing_fields?.length) return buildNeedInputResponse(parsed);
1112
+ const requiredConfirmations = buildRequiredConfirmations(parsed, args);
1113
+ if (requiredConfirmations.length) {
1114
+ return buildNeedConfirmationResponse(parsed, args, requiredConfirmations);
1115
+ }
1116
+
1117
+ if (args.follow_up?.chat || args.overrides?.follow_up?.chat) {
1118
+ return {
1119
+ status: "FAILED",
1120
+ error: {
1121
+ code: "FOLLOW_UP_CHAT_NOT_CDP_REWRITTEN",
1122
+ message: "recommend -> chat follow-up orchestration is legacy-only and intentionally fenced from the CDP-only MCP route. Run recommend first, then use the direct chat MCP route separately, or keep the old chained behavior in the archived legacy lane.",
1123
+ retryable: true
1124
+ },
1125
+ review: parsed.review
1126
+ };
1127
+ }
1128
+
1129
+ return null;
1130
+ }
1131
+
1132
+ function toArray(value) {
1133
+ if (Array.isArray(value)) return value;
1134
+ if (value === undefined || value === null) return [];
1135
+ return [value];
1136
+ }
1137
+
1138
+ function withoutUnlimited(values = []) {
1139
+ return toArray(values)
1140
+ .map((value) => normalizeText(value))
1141
+ .filter((value) => value && value !== "不限" && value.toLowerCase() !== "all" && value !== "全部");
1142
+ }
1143
+
1144
+ function buildRecommendFilter(parsed, args = {}) {
1145
+ if (args.no_filter === true || args.filter_enabled === false) {
1146
+ return { enabled: false };
1147
+ }
1148
+
1149
+ const groups = [];
1150
+ const recentNotView = withoutUnlimited(parsed.searchParams?.recent_not_view);
1151
+ if (recentNotView.length) {
1152
+ groups.push({
1153
+ group: "recentNotView",
1154
+ labels: recentNotView,
1155
+ selectAllLabels: true
1156
+ });
1157
+ }
1158
+
1159
+ const degree = withoutUnlimited(parsed.searchParams?.degree);
1160
+ if (degree.length) {
1161
+ groups.push({
1162
+ group: "degree",
1163
+ labels: degree,
1164
+ selectAllLabels: true
1165
+ });
1166
+ }
1167
+
1168
+ const gender = withoutUnlimited(parsed.searchParams?.gender);
1169
+ if (gender.length) {
1170
+ groups.push({
1171
+ group: "gender",
1172
+ labels: gender,
1173
+ selectAllLabels: true
1174
+ });
1175
+ }
1176
+
1177
+ const school = withoutUnlimited(parsed.searchParams?.school_tag);
1178
+ if (school.length) {
1179
+ groups.push({
1180
+ group: "school",
1181
+ labels: school,
1182
+ selectAllLabels: true
1183
+ });
1184
+ }
1185
+
1186
+ return groups.length ? { filterGroups: groups } : { enabled: false };
1187
+ }
1188
+
1189
+ function normalizeRecommendStartInput(args = {}, parsed, configResolution = null) {
1190
+ const confirmation = args.confirmation || {};
1191
+ const overrides = args.overrides || {};
1192
+ const slowLive = args.slow_live === true;
1193
+ const targetCount = parsePositiveInteger(
1194
+ args.max_candidates,
1195
+ parsed.screenParams?.target_count || parsePositiveInteger(confirmation.target_count_value, 5)
1196
+ );
1197
+ return {
1198
+ host: normalizeText(args.host) || DEFAULT_RECOMMEND_HOST,
1199
+ port: parsePositiveInteger(
1200
+ args.port,
1201
+ configResolution?.ok ? configResolution.config.debugPort : DEFAULT_RECOMMEND_PORT
1202
+ ),
1203
+ targetUrlIncludes: normalizeText(args.target_url_includes) || RECOMMEND_TARGET_URL,
1204
+ allowNavigate: args.allow_navigate !== false,
1205
+ slowLive,
1206
+ criteria: parsed.screenParams?.criteria || normalizeText(overrides.criteria),
1207
+ targetCount,
1208
+ job: normalizeText(confirmation.job_value || overrides.job || ""),
1209
+ pageScope: parsed.page_scope || "recommend",
1210
+ filter: buildRecommendFilter(parsed, args),
1211
+ postAction: parsed.screenParams?.post_action || "none",
1212
+ maxGreetCount: Number.isInteger(parsed.screenParams?.max_greet_count)
1213
+ ? parsed.screenParams.max_greet_count
1214
+ : null,
1215
+ screeningMode: normalizeScreeningModeArg(args)
1216
+ };
1217
+ }
1218
+
1219
+ function getRunOptions(args, parsed, normalized, session, configResolution = null) {
1220
+ const slowLive = args.slow_live === true;
1221
+ const executePostAction = args.dry_run_post_action === true
1222
+ ? false
1223
+ : args.execute_post_action !== false;
1224
+ return {
1225
+ client: session.client,
1226
+ targetUrl: RECOMMEND_TARGET_URL,
1227
+ criteria: normalized.criteria,
1228
+ jobLabel: normalized.job,
1229
+ pageScope: normalized.pageScope,
1230
+ fallbackPageScope: "recommend",
1231
+ filter: normalized.filter,
1232
+ maxCandidates: normalized.targetCount,
1233
+ detailLimit: resolveRecommendDetailLimit(args, normalized),
1234
+ closeDetail: true,
1235
+ delayMs: parseNonNegativeInteger(args.delay_ms, 0),
1236
+ cardTimeoutMs: slowLive ? 180000 : 90000,
1237
+ maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
1238
+ imageWheelDeltaY: parsePositiveInteger(args.image_wheel_delta_y, 650),
1239
+ cvAcquisitionMode: normalizeText(args.cv_acquisition_mode) || "unknown",
1240
+ listMaxScrolls: parsePositiveInteger(args.list_max_scrolls, 20),
1241
+ listStableSignatureLimit: parsePositiveInteger(args.list_stable_signature_limit, 2),
1242
+ listWheelDeltaY: parsePositiveInteger(args.list_wheel_delta_y, 850),
1243
+ listSettleMs: parsePositiveInteger(args.list_settle_ms, slowLive ? 1800 : 1200),
1244
+ listFallbackPoint: null,
1245
+ refreshOnEnd: args.refresh_on_end !== false,
1246
+ maxRefreshRounds: parseNonNegativeInteger(args.max_refresh_rounds, 2),
1247
+ refreshButtonSettleMs: parsePositiveInteger(args.refresh_button_settle_ms, slowLive ? 10000 : 8000),
1248
+ refreshReloadSettleMs: parsePositiveInteger(args.refresh_reload_settle_ms, slowLive ? 12000 : 8000),
1249
+ postAction: normalized.postAction,
1250
+ maxGreetCount: normalized.maxGreetCount,
1251
+ executePostAction,
1252
+ actionTimeoutMs: parsePositiveInteger(args.action_timeout_ms, slowLive ? 12000 : 8000),
1253
+ actionIntervalMs: parsePositiveInteger(args.action_interval_ms, 500),
1254
+ actionAfterClickDelayMs: parseNonNegativeInteger(args.action_after_click_delay_ms, slowLive ? 1200 : 900),
1255
+ screeningMode: normalized.screeningMode,
1256
+ llmConfig: normalized.screeningMode === "llm" && configResolution?.ok ? {
1257
+ ...configResolution.config
1258
+ } : null,
1259
+ llmTimeoutMs: parsePositiveInteger(
1260
+ args.llm_timeout_ms,
1261
+ parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
1262
+ ),
1263
+ llmImageLimit: parsePositiveInteger(
1264
+ args.llm_image_limit,
1265
+ parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
1266
+ ),
1267
+ llmImageDetail: normalizeText(
1268
+ args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
1269
+ ) || "low",
1270
+ imageOutputDir: resolveBossConfiguredOutputDir("", getRunsDir()),
1271
+ name: "mcp-recommend-pipeline-run",
1272
+ parsed
1273
+ };
1274
+ }
1275
+
1276
+ function prepareRecommendPipelineStart(args = {}, { workspaceRoot = "" } = {}) {
1277
+ const parsed = parseRecommendPipelineRequest(args);
1278
+ const gate = evaluateRecommendPipelineGate(parsed, args);
1279
+ if (gate) return { response: gate };
1280
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
1281
+ const normalized = normalizeRecommendStartInput(args, parsed, configResolution);
1282
+ const debugTestOptions = collectRecommendDebugTestOptions(args, normalized);
1283
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
1284
+ return {
1285
+ response: {
1286
+ status: "FAILED",
1287
+ error: {
1288
+ code: "DEBUG_TEST_MODE_REQUIRED",
1289
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
1290
+ retryable: false
1291
+ },
1292
+ debug_test_options: debugTestOptions
1293
+ }
1294
+ };
1295
+ }
1296
+ if (normalized.screeningMode === "llm" && !configResolution.ok) {
1297
+ return {
1298
+ response: {
1299
+ status: "FAILED",
1300
+ error: {
1301
+ code: "SCREEN_CONFIG_ERROR",
1302
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
1303
+ retryable: true
1304
+ },
1305
+ config_path: configResolution.config_path || null,
1306
+ candidate_paths: configResolution.candidate_paths || []
1307
+ }
1308
+ };
1309
+ }
1310
+ return {
1311
+ parsed,
1312
+ configResolution,
1313
+ normalized
1314
+ };
1315
+ }
1316
+
1317
+ async function closeRecommendRunSession(runId) {
1318
+ const meta = recommendRunMeta.get(runId);
1319
+ if (!meta || meta.closed) return;
1320
+ try {
1321
+ try {
1322
+ if (meta.session?.client) {
1323
+ await closeRecommendDetail(meta.session.client, { attemptsLimit: 2 });
1324
+ }
1325
+ } catch {
1326
+ // Cleanup is best-effort once the run has settled.
1327
+ }
1328
+ assertNoForbiddenCdpCalls(meta.methodLog || []);
1329
+ } finally {
1330
+ meta.closed = true;
1331
+ try {
1332
+ await meta.session?.close?.();
1333
+ } catch {
1334
+ // Nothing actionable for the caller once the run has settled.
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ async function waitForRecommendRunTerminal(runId) {
1340
+ while (true) {
1341
+ try {
1342
+ const snapshot = recommendRunService.getRecommendRun(runId);
1343
+ if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
1344
+ } catch {
1345
+ return null;
1346
+ }
1347
+ await sleep(1000);
1348
+ }
1349
+ }
1350
+
1351
+ function trackRecommendRun(runId) {
1352
+ waitForRecommendRunTerminal(runId)
1353
+ .then((terminal) => {
1354
+ if (terminal) persistRecommendRunSnapshot(terminal);
1355
+ })
1356
+ .catch(() => null)
1357
+ .finally(() => {
1358
+ closeRecommendRunSession(runId).catch(() => {});
1359
+ });
1360
+ }
1361
+
1362
+ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = "", runId = "" } = {}) {
1363
+ const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1364
+ if (prepared.response) return prepared.response;
1365
+ const { parsed, configResolution, normalized } = prepared;
1366
+ const fixedRunId = normalizeRunId(runId);
1367
+ if (runId && !fixedRunId) {
1368
+ return {
1369
+ status: "FAILED",
1370
+ error: {
1371
+ code: "INVALID_RUN_ID",
1372
+ message: "run_id is invalid",
1373
+ retryable: false
1374
+ }
1375
+ };
1376
+ }
1377
+
1378
+ let session;
1379
+ try {
1380
+ session = await recommendConnectorImpl({
1381
+ host: normalized.host,
1382
+ port: normalized.port,
1383
+ targetUrlIncludes: normalized.targetUrlIncludes,
1384
+ allowNavigate: normalized.allowNavigate,
1385
+ slowLive: normalized.slowLive
1386
+ });
1387
+ } catch (error) {
1388
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1389
+ return {
1390
+ status: "FAILED",
1391
+ error: {
1392
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_RECOMMEND_PAGE_NOT_READY",
1393
+ message: error?.message || "Boss recommend page is not ready",
1394
+ requires_login: Boolean(error?.requires_login),
1395
+ login_url: error?.login_url || null,
1396
+ login_detection: error?.login_detection || null,
1397
+ chrome: error?.chrome || null,
1398
+ current_url: error?.current_url || null,
1399
+ target_url: error?.target_url || RECOMMEND_TARGET_URL,
1400
+ retryable: true
1401
+ },
1402
+ chrome: error?.chrome || null
1403
+ };
1404
+ }
1405
+
1406
+ let started;
1407
+ try {
1408
+ started = recommendRunService.startRecommendRun({
1409
+ ...getRunOptions(args, parsed, normalized, session, configResolution),
1410
+ runId: fixedRunId || undefined,
1411
+ pid: process.pid
1412
+ });
1413
+ } catch (error) {
1414
+ await session.close?.();
1415
+ return {
1416
+ status: "FAILED",
1417
+ error: {
1418
+ code: "RECOMMEND_RUN_START_FAILED",
1419
+ message: error?.message || "Failed to start recommend run",
1420
+ retryable: true
1421
+ }
1422
+ };
1423
+ }
1424
+
1425
+ recommendRunMeta.set(started.runId, {
1426
+ session,
1427
+ methodLog: session.methodLog || [],
1428
+ workspaceRoot: normalizeText(workspaceRoot) || process.cwd(),
1429
+ args: clonePlain(args, {}),
1430
+ normalized,
1431
+ parsed,
1432
+ chrome: {
1433
+ host: normalized.host,
1434
+ port: normalized.port,
1435
+ target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
1436
+ target_id: session.target?.id || null,
1437
+ auto_launch: session.chrome || null
1438
+ },
1439
+ health: session.health || null
1440
+ });
1441
+ trackRecommendRun(started.runId);
1442
+ const persistedStarted = persistRecommendRunSnapshot(started);
1443
+
1444
+ return {
1445
+ status: "ACCEPTED",
1446
+ run_id: persistedStarted.run_id,
1447
+ state: persistedStarted.state,
1448
+ run: persistedStarted,
1449
+ poll_after_sec: DEFAULT_RECOMMEND_POLL_AFTER_SEC,
1450
+ review: parsed.review,
1451
+ message: normalized.postAction === "none"
1452
+ ? "Recommend pipeline run started through the shared CDP-only recommend service. No post-action was requested."
1453
+ : `Recommend pipeline run started through the shared CDP-only recommend service with post_action=${normalized.postAction}${args.dry_run_post_action === true ? " in dry-run mode" : ""}.`,
1454
+ post_action: {
1455
+ requested: normalized.postAction,
1456
+ execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1457
+ max_greet_count: normalized.maxGreetCount
1458
+ },
1459
+ target_count_semantics: TARGET_COUNT_SEMANTICS
1460
+ };
1461
+ }
1462
+
1463
+ export function prepareRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1464
+ const prepared = prepareRecommendPipelineStart(args, { workspaceRoot });
1465
+ if (prepared.response) return prepared.response;
1466
+ const { parsed, normalized } = prepared;
1467
+ return {
1468
+ status: "READY",
1469
+ review: parsed.review,
1470
+ post_action: {
1471
+ requested: normalized.postAction,
1472
+ execute_post_action: args.dry_run_post_action === true ? false : args.execute_post_action !== false,
1473
+ max_greet_count: normalized.maxGreetCount
1474
+ },
1475
+ target_count_semantics: TARGET_COUNT_SEMANTICS
1476
+ };
1477
+ }
1478
+
1479
+ export async function startRecommendPipelineRunTool({ workspaceRoot = "", args = {}, runId = "" } = {}) {
1480
+ const started = await startRecommendPipelineRunInternal(args, { workspaceRoot, runId });
1481
+ if (started.status !== "ACCEPTED") return started;
1482
+ return attachMethodEvidence(started, started.run_id);
1483
+ }
1484
+
1485
+ export function getRecommendPipelineRunTool({ args = {} } = {}) {
1486
+ const runId = normalizeRunId(args.run_id || args.runId);
1487
+ if (!runId) {
1488
+ return {
1489
+ status: "FAILED",
1490
+ error: {
1491
+ code: "INVALID_RUN_ID",
1492
+ message: "run_id is required",
1493
+ retryable: false
1494
+ }
1495
+ };
1496
+ }
1497
+ try {
1498
+ const run = recommendRunService.getRecommendRun(runId);
1499
+ const normalizedRun = persistRecommendRunSnapshot(run);
1500
+ return attachMethodEvidence({
1501
+ status: "RUN_STATUS",
1502
+ run: normalizedRun
1503
+ }, runId);
1504
+ } catch {
1505
+ const persisted = readRecommendRunState(runId);
1506
+ if (persisted) {
1507
+ const reconciled = reconcilePersistedRecommendRunIfNeeded(persisted);
1508
+ return {
1509
+ status: "RUN_STATUS",
1510
+ run: reconciled,
1511
+ persistence: {
1512
+ source: "disk",
1513
+ active_control_available: false,
1514
+ stale_process_reconciled: reconciled?.state !== persisted.state
1515
+ },
1516
+ runtime_evaluate_used: false,
1517
+ method_summary: {},
1518
+ method_log: [],
1519
+ chrome: null
1520
+ };
1521
+ }
1522
+ return {
1523
+ status: "FAILED",
1524
+ error: {
1525
+ code: "RUN_NOT_FOUND",
1526
+ message: `No recommend run found for run_id=${runId}`,
1527
+ retryable: false
1528
+ }
1529
+ };
1530
+ }
1531
+ }
1532
+
1533
+ export function pauseRecommendPipelineRunTool({ args = {} } = {}) {
1534
+ const runId = normalizeRunId(args.run_id || args.runId);
1535
+ try {
1536
+ const before = recommendRunService.getRecommendRun(runId);
1537
+ if (TERMINAL_STATUSES.has(before.status)) {
1538
+ const normalizedBefore = persistRecommendRunSnapshot(before);
1539
+ return attachMethodEvidence({
1540
+ status: "PAUSE_IGNORED",
1541
+ run: normalizedBefore,
1542
+ message: "目标任务已结束,无需暂停。"
1543
+ }, runId);
1544
+ }
1545
+ if (before.status === RUN_STATUS_PAUSED) {
1546
+ const normalizedBefore = persistRecommendRunSnapshot(before);
1547
+ return attachMethodEvidence({
1548
+ status: "PAUSE_IGNORED",
1549
+ run: normalizedBefore,
1550
+ message: "目标任务已经处于 paused 状态。"
1551
+ }, runId);
1552
+ }
1553
+ const run = recommendRunService.pauseRecommendRun(runId);
1554
+ const normalizedRun = persistRecommendRunSnapshot(run);
1555
+ return attachMethodEvidence({
1556
+ status: "PAUSE_REQUESTED",
1557
+ run: normalizedRun,
1558
+ message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1559
+ }, runId);
1560
+ } catch {
1561
+ const persisted = readRecommendRunState(runId);
1562
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1563
+ return {
1564
+ status: "PAUSE_IGNORED",
1565
+ run: persisted,
1566
+ message: "目标任务已结束,无需暂停。",
1567
+ runtime_evaluate_used: false,
1568
+ method_summary: {},
1569
+ method_log: [],
1570
+ chrome: null
1571
+ };
1572
+ }
1573
+ return getRecommendPipelineRunTool({ args });
1574
+ }
1575
+ }
1576
+
1577
+ export function resumeRecommendPipelineRunTool({ args = {} } = {}) {
1578
+ const runId = normalizeRunId(args.run_id || args.runId);
1579
+ try {
1580
+ const before = recommendRunService.getRecommendRun(runId);
1581
+ if (TERMINAL_STATUSES.has(before.status)) {
1582
+ const normalizedBefore = persistRecommendRunSnapshot(before);
1583
+ return attachMethodEvidence({
1584
+ status: "FAILED",
1585
+ error: {
1586
+ code: "RUN_ALREADY_TERMINATED",
1587
+ message: "目标任务已结束,无法继续。",
1588
+ retryable: false
1589
+ },
1590
+ run: normalizedBefore
1591
+ }, runId);
1592
+ }
1593
+ if (before.status !== RUN_STATUS_PAUSED) {
1594
+ const normalizedBefore = persistRecommendRunSnapshot(before);
1595
+ return attachMethodEvidence({
1596
+ status: "FAILED",
1597
+ error: {
1598
+ code: "RUN_NOT_PAUSED",
1599
+ message: "仅 paused 状态的 run 才能继续。",
1600
+ retryable: true
1601
+ },
1602
+ run: normalizedBefore
1603
+ }, runId);
1604
+ }
1605
+ const run = recommendRunService.resumeRecommendRun(runId);
1606
+ const meta = getRecommendRunMeta(runId);
1607
+ if (meta) {
1608
+ meta.resumeCount = (meta.resumeCount || 0) + 1;
1609
+ meta.lastResumedAt = new Date().toISOString();
1610
+ }
1611
+ const normalizedRun = persistRecommendRunSnapshot(run);
1612
+ return attachMethodEvidence({
1613
+ status: "RESUME_REQUESTED",
1614
+ run: normalizedRun,
1615
+ poll_after_sec: DEFAULT_RECOMMEND_POLL_AFTER_SEC,
1616
+ message: "已恢复 Recommend run,请使用 get_recommend_pipeline_run 按需轮询。"
1617
+ }, runId);
1618
+ } catch {
1619
+ const persisted = readRecommendRunState(runId);
1620
+ if (persisted) {
1621
+ return {
1622
+ status: "FAILED",
1623
+ error: {
1624
+ code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1625
+ message: TERMINAL_STATUSES.has(persisted.state)
1626
+ ? "目标任务已结束,无法继续。"
1627
+ : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1628
+ retryable: !TERMINAL_STATUSES.has(persisted.state)
1629
+ },
1630
+ run: persisted,
1631
+ persistence: {
1632
+ source: "disk",
1633
+ active_control_available: false
1634
+ },
1635
+ runtime_evaluate_used: false,
1636
+ method_summary: {},
1637
+ method_log: [],
1638
+ chrome: null
1639
+ };
1640
+ }
1641
+ return getRecommendPipelineRunTool({ args });
1642
+ }
1643
+ }
1644
+
1645
+ export function cancelRecommendPipelineRunTool({ args = {} } = {}) {
1646
+ const runId = normalizeRunId(args.run_id || args.runId);
1647
+ try {
1648
+ const before = recommendRunService.getRecommendRun(runId);
1649
+ if (TERMINAL_STATUSES.has(before.status)) {
1650
+ const normalizedBefore = persistRecommendRunSnapshot(before);
1651
+ return attachMethodEvidence({
1652
+ status: "CANCEL_IGNORED",
1653
+ run: normalizedBefore,
1654
+ message: "目标任务已结束,无需取消。"
1655
+ }, runId);
1656
+ }
1657
+ const run = recommendRunService.cancelRecommendRun(runId);
1658
+ const normalizedRun = persistRecommendRunSnapshot(run);
1659
+ return attachMethodEvidence({
1660
+ status: "CANCEL_REQUESTED",
1661
+ run: normalizedRun,
1662
+ message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
1663
+ }, runId);
1664
+ } catch {
1665
+ const persisted = readRecommendRunState(runId);
1666
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1667
+ return {
1668
+ status: "CANCEL_IGNORED",
1669
+ run: persisted,
1670
+ message: "目标任务已结束,无需取消。",
1671
+ runtime_evaluate_used: false,
1672
+ method_summary: {},
1673
+ method_log: [],
1674
+ chrome: null
1675
+ };
1676
+ }
1677
+ return getRecommendPipelineRunTool({ args });
1678
+ }
1679
+ }
1680
+
1681
+ export function getRecommendMcpHealthSnapshot(runId) {
1682
+ const meta = getRecommendRunMeta(runId);
1683
+ return {
1684
+ health: compactHealth(meta.health || null),
1685
+ chrome: meta.chrome || null,
1686
+ method_summary: methodSummary(meta.methodLog || [])
1687
+ };
1688
+ }
1689
+
1690
+ export function __setRecommendMcpConnectorForTests(nextConnector) {
1691
+ recommendConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecommendChromeSession;
1692
+ }
1693
+
1694
+ export function __setRecommendMcpJobReaderForTests(nextReader) {
1695
+ recommendJobReaderImpl = typeof nextReader === "function" ? nextReader : readRecommendJobOptionsFromSession;
1696
+ }
1697
+
1698
+ export function __setRecommendMcpWorkflowForTests(nextWorkflow) {
1699
+ recommendWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecommendWorkflow;
1700
+ recommendRunService = createRecommendRunService({
1701
+ idPrefix: "mcp_recommend",
1702
+ workflow: (...args) => recommendWorkflowImpl(...args),
1703
+ onSnapshot: persistRecommendLifecycleSnapshot
1704
+ });
1705
+ }
1706
+
1707
+ export function __resetRecommendMcpStateForTests() {
1708
+ for (const meta of recommendRunMeta.values()) {
1709
+ try {
1710
+ meta.session?.close?.();
1711
+ } catch {
1712
+ // Best-effort test cleanup.
1713
+ }
1714
+ }
1715
+ recommendRunMeta.clear();
1716
+ __setRecommendMcpConnectorForTests(null);
1717
+ __setRecommendMcpJobReaderForTests(null);
1718
+ __setRecommendMcpWorkflowForTests(null);
1719
+ }