@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/README.md +24 -3
- package/package.json +3 -1
- package/skills/boss-recruit-pipeline/SKILL.md +11 -2
- package/src/adapters.js +204 -7
- package/src/index.js +608 -80
- package/src/pipeline.js +230 -11
- package/src/run-state.js +294 -0
- package/src/test-index-async.js +231 -0
- package/src/test-run-state.js +115 -0
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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,
|
package/src/run-state.js
ADDED
|
@@ -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
|
+
|