@reconcrap/boss-recruit-mcp 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pipeline.js CHANGED
@@ -8,10 +8,16 @@ import {
8
8
  runScreenCli
9
9
  } from "./adapters.js";
10
10
 
11
+ export const PIPELINE_STATUS_READY_TO_START_ASYNC = "READY_TO_START_ASYNC";
12
+
11
13
  function dedupe(values = []) {
12
14
  return [...new Set(values.filter(Boolean))];
13
15
  }
14
16
 
17
+ function normalizeText(value) {
18
+ return String(value || "").replace(/\s+/g, " ").trim();
19
+ }
20
+
15
21
  function failedCheckSet(checks = []) {
16
22
  const failed = checks
17
23
  .filter((item) => item && item.ok === false && typeof item.key === "string")
@@ -195,6 +201,99 @@ function buildFailedResponse(code, message, extra = {}) {
195
201
  };
196
202
  }
197
203
 
204
+ class PipelineAbortError extends Error {
205
+ constructor(message = "Pipeline execution aborted") {
206
+ super(message);
207
+ this.name = "PipelineAbortError";
208
+ this.code = "PIPELINE_ABORTED";
209
+ }
210
+ }
211
+
212
+ function isAbortSignalTriggered(signal) {
213
+ return Boolean(signal && signal.aborted);
214
+ }
215
+
216
+ function ensurePipelineNotAborted(signal) {
217
+ if (isAbortSignalTriggered(signal)) {
218
+ throw new PipelineAbortError("Pipeline execution aborted by caller.");
219
+ }
220
+ }
221
+
222
+ function safeInvokeRuntimeCallback(callback, payload) {
223
+ if (typeof callback !== "function") return;
224
+ try {
225
+ callback(payload);
226
+ } catch {
227
+ // Keep pipeline stable even if runtime callback fails.
228
+ }
229
+ }
230
+
231
+ function createPipelineRuntime(runtime = null) {
232
+ const signal = runtime?.signal;
233
+ const heartbeatIntervalMs = Number.isFinite(runtime?.heartbeatIntervalMs) && runtime.heartbeatIntervalMs > 0
234
+ ? runtime.heartbeatIntervalMs
235
+ : 10_000;
236
+ const precheckOnly = runtime?.precheckOnly === true;
237
+
238
+ function setStage(stage, message = null) {
239
+ safeInvokeRuntimeCallback(runtime?.onStage, {
240
+ stage,
241
+ message: normalizeText(message || "") || null,
242
+ at: new Date().toISOString()
243
+ });
244
+ }
245
+
246
+ function heartbeat(stage, details = null) {
247
+ safeInvokeRuntimeCallback(runtime?.onHeartbeat, {
248
+ stage,
249
+ details: details || null,
250
+ at: new Date().toISOString()
251
+ });
252
+ }
253
+
254
+ function output(stage, event) {
255
+ safeInvokeRuntimeCallback(runtime?.onOutput, {
256
+ stage,
257
+ ...(event || {}),
258
+ at: new Date().toISOString()
259
+ });
260
+ }
261
+
262
+ function progress(stage, payload) {
263
+ safeInvokeRuntimeCallback(runtime?.onProgress, {
264
+ stage,
265
+ ...(payload || {}),
266
+ at: new Date().toISOString()
267
+ });
268
+ }
269
+
270
+ function adapterRuntime(stage) {
271
+ return {
272
+ signal,
273
+ heartbeatIntervalMs,
274
+ onOutput: (event) => output(stage, event),
275
+ onHeartbeat: (event) => heartbeat(stage, event),
276
+ onProgress: (payload) => progress(stage, payload)
277
+ };
278
+ }
279
+
280
+ return {
281
+ signal,
282
+ heartbeatIntervalMs,
283
+ precheckOnly,
284
+ setStage,
285
+ heartbeat,
286
+ output,
287
+ progress,
288
+ adapterRuntime
289
+ };
290
+ }
291
+
292
+ function isProcessAbortError(errorLike) {
293
+ const code = normalizeText(errorLike?.code || errorLike?.error_code || "").toUpperCase();
294
+ return code === "PROCESS_ABORTED" || code === "ABORTED";
295
+ }
296
+
198
297
  function normalizeCsvPath(csvPath) {
199
298
  if (typeof csvPath !== "string") return null;
200
299
  const trimmed = csvPath.trim();
@@ -365,12 +464,16 @@ const defaultDependencies = {
365
464
  runScreenCli
366
465
  };
367
466
 
368
- export async function runRecruitPipeline({
369
- workspaceRoot,
370
- instruction,
371
- confirmation,
372
- overrides
373
- }, dependencies = defaultDependencies) {
467
+ export async function runRecruitPipeline(
468
+ {
469
+ workspaceRoot,
470
+ instruction,
471
+ confirmation,
472
+ overrides
473
+ },
474
+ dependencies = defaultDependencies,
475
+ runtime = null
476
+ ) {
374
477
  const {
375
478
  parseRecruitInstruction: parseInstruction,
376
479
  ensureBossSearchPageReady: ensureSearchPageReady,
@@ -378,6 +481,8 @@ export async function runRecruitPipeline({
378
481
  runSearchCli: searchCli,
379
482
  runScreenCli: screenCli
380
483
  } = dependencies;
484
+ const runtimeHooks = createPipelineRuntime(runtime);
485
+ ensurePipelineNotAborted(runtimeHooks.signal);
381
486
  const startedAt = Date.now();
382
487
  const parsed = parseInstruction({
383
488
  instruction,
@@ -398,8 +503,14 @@ export async function runRecruitPipeline({
398
503
  return buildNeedConfirmationResponse(parsed);
399
504
  }
400
505
 
506
+ ensurePipelineNotAborted(runtimeHooks.signal);
507
+ runtimeHooks.setStage("preflight", "开始执行 preflight 检查。");
508
+ runtimeHooks.heartbeat("preflight");
401
509
  const preflight = runPreflight(workspaceRoot);
402
510
  if (!preflight.ok) {
511
+ runtimeHooks.heartbeat("preflight", {
512
+ status: "failed"
513
+ });
403
514
  const recovery = buildPreflightRecovery(preflight.checks, workspaceRoot);
404
515
  return buildFailedResponse(
405
516
  "PIPELINE_PREFLIGHT_FAILED",
@@ -429,12 +540,70 @@ export async function runRecruitPipeline({
429
540
  );
430
541
  }
431
542
 
543
+ ensurePipelineNotAborted(runtimeHooks.signal);
544
+ runtimeHooks.setStage("page_ready", "preflight 完成,开始检查 search 页面就绪状态。");
545
+ runtimeHooks.heartbeat("page_ready");
546
+ const initialPageCheck = await ensureSearchPageReady(workspaceRoot, {
547
+ port: preflight.debug_port
548
+ });
549
+ if (!initialPageCheck.ok) {
550
+ if (
551
+ initialPageCheck.state === "LOGIN_REQUIRED"
552
+ || initialPageCheck.state === "LOGIN_REQUIRED_AFTER_REDIRECT"
553
+ ) {
554
+ return buildFailedResponse(
555
+ "BOSS_LOGIN_REQUIRED",
556
+ "Boss 页面未稳定停留在 search 页面,疑似未登录或登录态失效。请先在当前 Chrome 窗口手动登录 Boss,登录完成后再继续搜索和筛选。",
557
+ {
558
+ search_params: parsed.searchParams,
559
+ screen_params: parsed.screenParams,
560
+ diagnostics: buildProgressDiagnostics({
561
+ preflight,
562
+ totalProcessedCount: 0,
563
+ totalPassedCount: 0,
564
+ roundCount: 0,
565
+ extra: {
566
+ page_state: initialPageCheck.page_state
567
+ }
568
+ })
569
+ }
570
+ );
571
+ }
572
+ return buildFailedResponse(
573
+ "BOSS_SEARCH_PAGE_NOT_READY",
574
+ "无法确认 Boss search 页面已就绪。请先确保 Chrome 调试端口可连,并且页面能稳定停留在 https://www.zhipin.com/web/chat/search。",
575
+ {
576
+ search_params: parsed.searchParams,
577
+ screen_params: parsed.screenParams,
578
+ diagnostics: buildProgressDiagnostics({
579
+ preflight,
580
+ totalProcessedCount: 0,
581
+ totalPassedCount: 0,
582
+ roundCount: 0,
583
+ extra: {
584
+ page_state: initialPageCheck.page_state
585
+ }
586
+ })
587
+ }
588
+ );
589
+ }
590
+
591
+ if (runtimeHooks.precheckOnly) {
592
+ return {
593
+ status: PIPELINE_STATUS_READY_TO_START_ASYNC,
594
+ search_params: parsed.searchParams,
595
+ screen_params: parsed.screenParams,
596
+ message: "前置门禁检查通过,可启动异步流水线。"
597
+ };
598
+ }
599
+
432
600
  let totalProcessedCount = 0;
433
601
  let totalPassedCount = 0;
434
602
  let roundCount = 0;
435
603
  const roundOutputCsvPaths = [];
436
604
 
437
605
  while (totalProcessedCount < initialTargetCount) {
606
+ ensurePipelineNotAborted(runtimeHooks.signal);
438
607
  roundCount += 1;
439
608
 
440
609
  const remainingTargetCount = Math.max(0, initialTargetCount - totalProcessedCount);
@@ -447,9 +616,17 @@ export async function runRecruitPipeline({
447
616
  target_count: remainingTargetCount
448
617
  };
449
618
 
450
- const pageCheck = await ensureSearchPageReady(workspaceRoot, {
451
- port: preflight.debug_port
452
- });
619
+ if (roundCount > 1) {
620
+ runtimeHooks.setStage("page_ready", `第 ${roundCount} 轮:检查 search 页面就绪状态。`);
621
+ runtimeHooks.heartbeat("page_ready", {
622
+ round: roundCount
623
+ });
624
+ }
625
+ const pageCheck = roundCount === 1
626
+ ? initialPageCheck
627
+ : await ensureSearchPageReady(workspaceRoot, {
628
+ port: preflight.debug_port
629
+ });
453
630
  if (!pageCheck.ok) {
454
631
  if (
455
632
  pageCheck.state === "LOGIN_REQUIRED"
@@ -493,10 +670,20 @@ export async function runRecruitPipeline({
493
670
  );
494
671
  }
495
672
 
673
+ ensurePipelineNotAborted(runtimeHooks.signal);
674
+ runtimeHooks.setStage("search", `第 ${roundCount} 轮:开始执行 search。`);
675
+ runtimeHooks.heartbeat("search", {
676
+ round: roundCount
677
+ });
496
678
  const searchResult = await searchCli({
497
679
  workspaceRoot,
498
- searchParams: roundSearchParams
680
+ searchParams: roundSearchParams,
681
+ runtime: runtimeHooks.adapterRuntime("search")
499
682
  });
683
+ ensurePipelineNotAborted(runtimeHooks.signal);
684
+ if (isProcessAbortError(searchResult)) {
685
+ throw new PipelineAbortError("搜索流程已取消。");
686
+ }
500
687
 
501
688
  if (!searchResult.ok) {
502
689
  const failure = classifySearchFailure(searchResult);
@@ -545,6 +732,14 @@ export async function runRecruitPipeline({
545
732
 
546
733
  const exhaustedByTipNoData = searchResult.no_data_tip_present === true;
547
734
  if (exhaustedByTipNoData || searchResult.candidate_count === 0) {
735
+ runtimeHooks.setStage("finalize", "候选池已耗尽,正在汇总结果。");
736
+ runtimeHooks.heartbeat("finalize");
737
+ runtimeHooks.progress("finalize", {
738
+ processed: totalProcessedCount,
739
+ passed: totalPassedCount,
740
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
741
+ greet_count: 0
742
+ });
548
743
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
549
744
  const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
550
745
  return {
@@ -568,10 +763,20 @@ export async function runRecruitPipeline({
568
763
  };
569
764
  }
570
765
 
766
+ ensurePipelineNotAborted(runtimeHooks.signal);
767
+ runtimeHooks.setStage("screen", `第 ${roundCount} 轮:开始执行 screen。`);
768
+ runtimeHooks.heartbeat("screen", {
769
+ round: roundCount
770
+ });
571
771
  const screenResult = await screenCli({
572
772
  workspaceRoot,
573
- screenParams: roundScreenParams
773
+ screenParams: roundScreenParams,
774
+ runtime: runtimeHooks.adapterRuntime("screen")
574
775
  });
776
+ ensurePipelineNotAborted(runtimeHooks.signal);
777
+ if (isProcessAbortError(screenResult)) {
778
+ throw new PipelineAbortError("筛选流程已取消。");
779
+ }
575
780
 
576
781
  if (!screenResult.ok) {
577
782
  const failure = classifyScreenFailure(screenResult);
@@ -634,10 +839,24 @@ export async function runRecruitPipeline({
634
839
  : 0;
635
840
  totalProcessedCount += roundProcessedCount;
636
841
  totalPassedCount += roundPassedCount;
842
+ runtimeHooks.progress("screen", {
843
+ processed: totalProcessedCount,
844
+ passed: totalPassedCount,
845
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
846
+ greet_count: 0
847
+ });
637
848
  }
638
849
 
850
+ runtimeHooks.setStage("finalize", "筛选完成,正在汇总结果。");
851
+ runtimeHooks.heartbeat("finalize");
639
852
  const durationSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
640
853
  const mergedCsvPath = mergeRoundCsvFiles(roundOutputCsvPaths);
854
+ runtimeHooks.progress("finalize", {
855
+ processed: totalProcessedCount,
856
+ passed: totalPassedCount,
857
+ skipped: Math.max(totalProcessedCount - totalPassedCount, 0),
858
+ greet_count: 0
859
+ });
641
860
  return {
642
861
  status: "COMPLETED",
643
862
  search_params: parsed.searchParams,
@@ -0,0 +1,294 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const RUN_MODE_SYNC = "sync";
7
+ export const RUN_MODE_ASYNC = "async";
8
+
9
+ export const RUN_STATE_QUEUED = "queued";
10
+ export const RUN_STATE_RUNNING = "running";
11
+ export const RUN_STATE_COMPLETED = "completed";
12
+ export const RUN_STATE_FAILED = "failed";
13
+ export const RUN_STATE_CANCELED = "canceled";
14
+
15
+ export const RUN_STAGE_PREFLIGHT = "preflight";
16
+ export const RUN_STAGE_PAGE_READY = "page_ready";
17
+ export const RUN_STAGE_JOB_LIST = "job_list";
18
+ export const RUN_STAGE_SEARCH = "search";
19
+ export const RUN_STAGE_SCREEN = "screen";
20
+ export const RUN_STAGE_FINALIZE = "finalize";
21
+
22
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 120_000;
23
+ const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
24
+
25
+ const VALID_RUN_MODES = new Set([RUN_MODE_SYNC, RUN_MODE_ASYNC]);
26
+ const VALID_RUN_STATES = new Set([
27
+ RUN_STATE_QUEUED,
28
+ RUN_STATE_RUNNING,
29
+ RUN_STATE_COMPLETED,
30
+ RUN_STATE_FAILED,
31
+ RUN_STATE_CANCELED
32
+ ]);
33
+ const VALID_RUN_STAGES = new Set([
34
+ RUN_STAGE_PREFLIGHT,
35
+ RUN_STAGE_PAGE_READY,
36
+ RUN_STAGE_JOB_LIST,
37
+ RUN_STAGE_SEARCH,
38
+ RUN_STAGE_SCREEN,
39
+ RUN_STAGE_FINALIZE
40
+ ]);
41
+
42
+ function toIsoNow() {
43
+ return new Date().toISOString();
44
+ }
45
+
46
+ function parsePositiveInteger(raw, fallback) {
47
+ const value = Number.parseInt(String(raw || ""), 10);
48
+ return Number.isFinite(value) && value > 0 ? value : fallback;
49
+ }
50
+
51
+ export function getRunHeartbeatIntervalMs() {
52
+ return parsePositiveInteger(process.env.BOSS_RECRUIT_RUN_HEARTBEAT_MS, DEFAULT_HEARTBEAT_INTERVAL_MS);
53
+ }
54
+
55
+ export function getRunRetentionMs() {
56
+ return parsePositiveInteger(process.env.BOSS_RECRUIT_RUN_RETENTION_MS, DEFAULT_RETENTION_MS);
57
+ }
58
+
59
+ export function getStateHome() {
60
+ return process.env.BOSS_RECRUIT_HOME
61
+ ? path.resolve(process.env.BOSS_RECRUIT_HOME)
62
+ : path.join(os.homedir(), "\.boss-recruit-mcp");
63
+ }
64
+
65
+ export function getRunsDir() {
66
+ return path.join(getStateHome(), "runs");
67
+ }
68
+
69
+ function ensureRunsDir() {
70
+ fs.mkdirSync(getRunsDir(), { recursive: true });
71
+ }
72
+
73
+ function normalizeRunId(runId) {
74
+ return String(runId || "").trim();
75
+ }
76
+
77
+ function getRunStatePath(runId) {
78
+ const normalized = normalizeRunId(runId);
79
+ if (!normalized || normalized.includes("/") || normalized.includes("\\")) {
80
+ throw new Error("Invalid run_id");
81
+ }
82
+ return path.join(getRunsDir(), `${normalized}.json`);
83
+ }
84
+
85
+ function safeReadJson(filePath) {
86
+ try {
87
+ if (!fs.existsSync(filePath)) return null;
88
+ const raw = fs.readFileSync(filePath, "utf8");
89
+ const parsed = JSON.parse(raw);
90
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function safeWriteJson(filePath, payload) {
97
+ ensureRunsDir();
98
+ const tempPath = `${filePath}.tmp`;
99
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
100
+ fs.renameSync(tempPath, filePath);
101
+ }
102
+
103
+ function defaultProgress(progress = {}) {
104
+ return {
105
+ processed: Number.isInteger(progress.processed) && progress.processed >= 0 ? progress.processed : 0,
106
+ passed: Number.isInteger(progress.passed) && progress.passed >= 0 ? progress.passed : 0,
107
+ skipped: Number.isInteger(progress.skipped) && progress.skipped >= 0 ? progress.skipped : 0,
108
+ greet_count: Number.isInteger(progress.greet_count) && progress.greet_count >= 0 ? progress.greet_count : 0
109
+ };
110
+ }
111
+
112
+ function normalizeRunMode(mode) {
113
+ const normalized = String(mode || "").trim().toLowerCase();
114
+ return VALID_RUN_MODES.has(normalized) ? normalized : RUN_MODE_SYNC;
115
+ }
116
+
117
+ function normalizeRunState(state) {
118
+ const normalized = String(state || "").trim().toLowerCase();
119
+ return VALID_RUN_STATES.has(normalized) ? normalized : RUN_STATE_QUEUED;
120
+ }
121
+
122
+ function normalizeRunStage(stage) {
123
+ const normalized = String(stage || "").trim().toLowerCase();
124
+ return VALID_RUN_STAGES.has(normalized) ? normalized : RUN_STAGE_PREFLIGHT;
125
+ }
126
+
127
+ function normalizeMessage(message) {
128
+ const normalized = String(message || "").replace(/\s+/g, " ").trim();
129
+ return normalized || null;
130
+ }
131
+
132
+ export function createRunId() {
133
+ if (typeof crypto.randomUUID === "function") {
134
+ return crypto.randomUUID();
135
+ }
136
+ return crypto.randomBytes(16).toString("hex");
137
+ }
138
+
139
+ export function createRunStateSnapshot({
140
+ runId,
141
+ mode = RUN_MODE_SYNC,
142
+ state = RUN_STATE_QUEUED,
143
+ stage = RUN_STAGE_PREFLIGHT,
144
+ pid = process.pid,
145
+ lastMessage = null
146
+ } = {}) {
147
+ const now = toIsoNow();
148
+ return {
149
+ run_id: normalizeRunId(runId) || createRunId(),
150
+ mode: normalizeRunMode(mode),
151
+ state: normalizeRunState(state),
152
+ stage: normalizeRunStage(stage),
153
+ started_at: now,
154
+ updated_at: now,
155
+ heartbeat_at: now,
156
+ pid: Number.isInteger(pid) && pid > 0 ? pid : process.pid,
157
+ progress: defaultProgress(),
158
+ last_message: normalizeMessage(lastMessage),
159
+ error: null,
160
+ result: null
161
+ };
162
+ }
163
+
164
+ export function writeRunState(snapshot) {
165
+ const runId = normalizeRunId(snapshot?.run_id);
166
+ if (!runId) {
167
+ throw new Error("run_id is required");
168
+ }
169
+ const now = toIsoNow();
170
+ const payload = {
171
+ run_id: runId,
172
+ mode: normalizeRunMode(snapshot.mode),
173
+ state: normalizeRunState(snapshot.state),
174
+ stage: normalizeRunStage(snapshot.stage),
175
+ started_at: String(snapshot.started_at || now),
176
+ updated_at: String(snapshot.updated_at || now),
177
+ heartbeat_at: String(snapshot.heartbeat_at || now),
178
+ pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid,
179
+ progress: defaultProgress(snapshot.progress),
180
+ last_message: normalizeMessage(snapshot.last_message),
181
+ error: snapshot.error || null,
182
+ result: snapshot.result || null
183
+ };
184
+ safeWriteJson(getRunStatePath(runId), payload);
185
+ return payload;
186
+ }
187
+
188
+ export function readRunState(runId) {
189
+ const payload = safeReadJson(getRunStatePath(runId));
190
+ if (!payload) return null;
191
+ return {
192
+ run_id: normalizeRunId(payload.run_id),
193
+ mode: normalizeRunMode(payload.mode),
194
+ state: normalizeRunState(payload.state),
195
+ stage: normalizeRunStage(payload.stage),
196
+ started_at: String(payload.started_at || ""),
197
+ updated_at: String(payload.updated_at || ""),
198
+ heartbeat_at: String(payload.heartbeat_at || ""),
199
+ pid: Number.isInteger(payload.pid) && payload.pid > 0 ? payload.pid : process.pid,
200
+ progress: defaultProgress(payload.progress),
201
+ last_message: normalizeMessage(payload.last_message),
202
+ error: payload.error || null,
203
+ result: payload.result || null
204
+ };
205
+ }
206
+
207
+ export function updateRunState(runId, updater) {
208
+ const current = readRunState(runId);
209
+ if (!current) return null;
210
+ const patch = typeof updater === "function" ? updater({ ...current }) : updater;
211
+ if (!patch || typeof patch !== "object") {
212
+ return current;
213
+ }
214
+ const now = toIsoNow();
215
+ const next = {
216
+ ...current,
217
+ ...patch,
218
+ run_id: current.run_id,
219
+ mode: normalizeRunMode(patch.mode ?? current.mode),
220
+ state: normalizeRunState(patch.state ?? current.state),
221
+ stage: normalizeRunStage(patch.stage ?? current.stage),
222
+ progress: defaultProgress({
223
+ ...current.progress,
224
+ ...(patch.progress || {})
225
+ }),
226
+ last_message: normalizeMessage(
227
+ Object.prototype.hasOwnProperty.call(patch, "last_message")
228
+ ? patch.last_message
229
+ : current.last_message
230
+ ),
231
+ updated_at: now,
232
+ heartbeat_at: String(
233
+ Object.prototype.hasOwnProperty.call(patch, "heartbeat_at")
234
+ ? (patch.heartbeat_at || now)
235
+ : current.heartbeat_at
236
+ )
237
+ };
238
+ return writeRunState(next);
239
+ }
240
+
241
+ export function touchRunHeartbeat(runId, message = null) {
242
+ return updateRunState(runId, (current) => ({
243
+ heartbeat_at: toIsoNow(),
244
+ last_message: message ?? current.last_message
245
+ }));
246
+ }
247
+
248
+ export function updateRunProgress(runId, progressPatch = {}, message = null) {
249
+ const patch = {
250
+ progress: {}
251
+ };
252
+ if (Number.isInteger(progressPatch.processed) && progressPatch.processed >= 0) {
253
+ patch.progress.processed = progressPatch.processed;
254
+ }
255
+ if (Number.isInteger(progressPatch.passed) && progressPatch.passed >= 0) {
256
+ patch.progress.passed = progressPatch.passed;
257
+ }
258
+ if (Number.isInteger(progressPatch.skipped) && progressPatch.skipped >= 0) {
259
+ patch.progress.skipped = progressPatch.skipped;
260
+ }
261
+ if (Number.isInteger(progressPatch.greet_count) && progressPatch.greet_count >= 0) {
262
+ patch.progress.greet_count = progressPatch.greet_count;
263
+ }
264
+ if (message !== null) {
265
+ patch.last_message = message;
266
+ }
267
+ return updateRunState(runId, patch);
268
+ }
269
+
270
+ export function cleanupExpiredRuns(retentionMs = getRunRetentionMs()) {
271
+ ensureRunsDir();
272
+ const removed = [];
273
+ const failed = [];
274
+ const now = Date.now();
275
+ const entries = fs.readdirSync(getRunsDir(), { withFileTypes: true });
276
+ for (const entry of entries) {
277
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
278
+ const filePath = path.join(getRunsDir(), entry.name);
279
+ try {
280
+ const stat = fs.statSync(filePath);
281
+ const age = now - Number(stat.mtimeMs || 0);
282
+ if (age < retentionMs) continue;
283
+ fs.unlinkSync(filePath);
284
+ removed.push(filePath);
285
+ } catch (error) {
286
+ failed.push({
287
+ file: filePath,
288
+ reason: error.message || String(error)
289
+ });
290
+ }
291
+ }
292
+ return { removed, failed };
293
+ }
294
+