@reconcrap/boss-recommend-mcp 2.0.50 → 2.0.52

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