@reconcrap/boss-recommend-mcp 2.0.38 → 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,1719 +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
- 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
- }
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
+ }