@reconcrap/boss-recruit-mcp 1.0.20 → 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.
@@ -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
+
@@ -0,0 +1,231 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { __testables } from "./index.js";
6
+
7
+ const {
8
+ handleRequest,
9
+ activeAsyncRuns,
10
+ setRunPipelineImplForTests
11
+ } = __testables;
12
+
13
+ function sleep(ms) {
14
+ return new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+
17
+ function makeToolCall(id, name, args = {}) {
18
+ return {
19
+ jsonrpc: "2.0",
20
+ id,
21
+ method: "tools/call",
22
+ params: {
23
+ name,
24
+ arguments: args
25
+ }
26
+ };
27
+ }
28
+
29
+ async function readToolPayload(response) {
30
+ return response?.result?.structuredContent;
31
+ }
32
+
33
+ async function waitForTerminalRunState(runId, timeoutMs = 4000) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ while (Date.now() < deadline) {
36
+ const response = await handleRequest(
37
+ makeToolCall(100, "get_recruit_pipeline_run", { run_id: runId }),
38
+ process.cwd()
39
+ );
40
+ const payload = await readToolPayload(response);
41
+ const state = payload?.run?.state;
42
+ if (["completed", "failed", "canceled"].includes(String(state || "").toLowerCase())) {
43
+ return payload.run;
44
+ }
45
+ await sleep(80);
46
+ }
47
+ throw new Error(`Timed out waiting terminal run state for run_id=${runId}`);
48
+ }
49
+
50
+ async function testAsyncStartStatusCancelAndSyncCompatibility() {
51
+ const previousHome = process.env.BOSS_RECRUIT_HOME;
52
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recruit-index-async-"));
53
+
54
+ setRunPipelineImplForTests(async (input, _deps, runtime) => {
55
+ if (runtime?.precheckOnly) {
56
+ if (input.instruction.includes("need-confirm")) {
57
+ return {
58
+ status: "NEED_CONFIRMATION",
59
+ required_confirmations: ["keyword"],
60
+ pending_questions: [
61
+ {
62
+ field: "keyword",
63
+ question: "请先确认关键词"
64
+ }
65
+ ]
66
+ };
67
+ }
68
+ if (input.instruction.includes("precheck-fail")) {
69
+ return {
70
+ status: "FAILED",
71
+ error: {
72
+ code: "BOSS_LOGIN_REQUIRED",
73
+ message: "mock login required",
74
+ retryable: true
75
+ }
76
+ };
77
+ }
78
+ return {
79
+ status: "READY_TO_START_ASYNC"
80
+ };
81
+ }
82
+
83
+ runtime?.onStage?.({ stage: "preflight", message: "preflight started" });
84
+ await sleep(50);
85
+
86
+ if (input.instruction.includes("fail")) {
87
+ runtime?.onStage?.({ stage: "search", message: "search failed" });
88
+ return {
89
+ status: "FAILED",
90
+ error: {
91
+ code: "SEARCH_CLI_FAILED",
92
+ message: "mock search failed",
93
+ retryable: true
94
+ }
95
+ };
96
+ }
97
+
98
+ runtime?.onStage?.({ stage: "screen", message: "screen running" });
99
+ for (let i = 1; i <= 40; i += 1) {
100
+ if (runtime?.signal?.aborted) {
101
+ const error = new Error("aborted");
102
+ error.code = "PIPELINE_ABORTED";
103
+ throw error;
104
+ }
105
+ runtime?.onProgress?.({
106
+ stage: "screen",
107
+ processed: i,
108
+ passed: Math.floor(i / 5),
109
+ skipped: i - Math.floor(i / 5),
110
+ greet_count: 0,
111
+ line: `处理第 ${i} 位候选人`
112
+ });
113
+ await sleep(input.instruction.includes("slow") ? 25 : 5);
114
+ }
115
+
116
+ return {
117
+ status: "COMPLETED",
118
+ result: {
119
+ processed_count: 40,
120
+ passed_count: 8
121
+ }
122
+ };
123
+ });
124
+
125
+ process.env.BOSS_RECRUIT_HOME = tempHome;
126
+
127
+ try {
128
+ const gatedStartResponse = await handleRequest(
129
+ makeToolCall(1, "start_recruit_pipeline_run", { instruction: "need-confirm slow task" }),
130
+ process.cwd()
131
+ );
132
+ const gatedStartPayload = await readToolPayload(gatedStartResponse);
133
+ assert.equal(gatedStartPayload.status, "NEED_CONFIRMATION");
134
+ assert.deepEqual(gatedStartPayload.required_confirmations, ["keyword"]);
135
+ assert.equal(gatedStartPayload.run_id, undefined);
136
+
137
+ const startResponse = await handleRequest(
138
+ makeToolCall(2, "start_recruit_pipeline_run", { instruction: "slow task for cancel" }),
139
+ process.cwd()
140
+ );
141
+ const started = await readToolPayload(startResponse);
142
+ assert.equal(started.status, "ACCEPTED");
143
+ assert.equal(typeof started.run_id, "string");
144
+ assert.equal(started.poll_after_sec >= 5 && started.poll_after_sec <= 15, true);
145
+
146
+ const statusResponse = await handleRequest(
147
+ makeToolCall(3, "get_recruit_pipeline_run", { run_id: started.run_id }),
148
+ process.cwd()
149
+ );
150
+ const initialStatus = await readToolPayload(statusResponse);
151
+ assert.equal(initialStatus.status, "RUN_STATUS");
152
+ assert.equal(["queued", "running"].includes(initialStatus.run.state), true);
153
+
154
+ const cancelResponse = await handleRequest(
155
+ makeToolCall(4, "cancel_recruit_pipeline_run", { run_id: started.run_id }),
156
+ process.cwd()
157
+ );
158
+ const canceled = await readToolPayload(cancelResponse);
159
+ assert.equal(["CANCEL_REQUESTED", "CANCEL_IGNORED"].includes(canceled.status), true);
160
+
161
+ const canceledRun = await waitForTerminalRunState(started.run_id);
162
+ assert.equal(canceledRun.state, "canceled");
163
+
164
+ const defaultAsyncGatedResponse = await handleRequest(
165
+ makeToolCall(5, "run_recruit_pipeline", { instruction: "need-confirm default async" }),
166
+ process.cwd()
167
+ );
168
+ const defaultAsyncGatedPayload = await readToolPayload(defaultAsyncGatedResponse);
169
+ assert.equal(defaultAsyncGatedPayload.status, "NEED_CONFIRMATION");
170
+ assert.deepEqual(defaultAsyncGatedPayload.required_confirmations, ["keyword"]);
171
+
172
+ const defaultAsyncResponse = await handleRequest(
173
+ makeToolCall(6, "run_recruit_pipeline", { instruction: "fast async accepted run" }),
174
+ process.cwd()
175
+ );
176
+ const defaultAsyncPayload = await readToolPayload(defaultAsyncResponse);
177
+ assert.equal(defaultAsyncPayload.status, "ACCEPTED");
178
+ assert.equal(typeof defaultAsyncPayload.run_id, "string");
179
+ const completedDefaultAsyncRun = await waitForTerminalRunState(defaultAsyncPayload.run_id);
180
+ assert.equal(completedDefaultAsyncRun.state, "completed");
181
+
182
+ const syncResponse = await handleRequest(
183
+ makeToolCall(7, "run_recruit_pipeline", {
184
+ instruction: "fast forced sync run",
185
+ execution_mode: "sync"
186
+ }),
187
+ process.cwd()
188
+ );
189
+ const syncPayload = await readToolPayload(syncResponse);
190
+ assert.equal(syncPayload.status, "COMPLETED");
191
+ assert.equal(typeof syncPayload.result.run_id, "string");
192
+ assert.equal(syncPayload.result.processed_count, 40);
193
+
194
+ const failedSyncResponse = await handleRequest(
195
+ makeToolCall(8, "run_recruit_pipeline", {
196
+ instruction: "force fail",
197
+ execution_mode: "sync"
198
+ }),
199
+ process.cwd()
200
+ );
201
+ const syncFailedPayload = await readToolPayload(failedSyncResponse);
202
+ assert.equal(syncFailedPayload.status, "FAILED");
203
+ assert.equal(typeof syncFailedPayload.diagnostics?.run_id, "string");
204
+ assert.equal(typeof syncFailedPayload.diagnostics?.last_stage, "string");
205
+
206
+ const precheckFailedResponse = await handleRequest(
207
+ makeToolCall(9, "start_recruit_pipeline_run", { instruction: "precheck-fail" }),
208
+ process.cwd()
209
+ );
210
+ const precheckFailedPayload = await readToolPayload(precheckFailedResponse);
211
+ assert.equal(precheckFailedPayload.status, "FAILED");
212
+ assert.equal(precheckFailedPayload.error.code, "BOSS_LOGIN_REQUIRED");
213
+
214
+ assert.equal(activeAsyncRuns.size >= 0, true);
215
+ } finally {
216
+ setRunPipelineImplForTests(null);
217
+ if (previousHome === undefined) {
218
+ delete process.env.BOSS_RECRUIT_HOME;
219
+ } else {
220
+ process.env.BOSS_RECRUIT_HOME = previousHome;
221
+ }
222
+ fs.rmSync(tempHome, { recursive: true, force: true });
223
+ }
224
+ }
225
+
226
+ async function main() {
227
+ await testAsyncStartStatusCancelAndSyncCompatibility();
228
+ console.log("index async tests passed");
229
+ }
230
+
231
+ await main();
@@ -302,39 +302,6 @@ function testCriteriaConfirmationPrompt() {
302
302
  assert.equal(confirmedCriteria.needs_criteria_confirmation, false);
303
303
  }
304
304
 
305
- function testNaturalSentenceExtractsCityAndKeywordWithoutMissing() {
306
- const r = parseRecruitInstruction({
307
- instruction: "找杭州本科及以上做算法的人,本科学校必须是211或qs100院校,有CCF-A论文或会议成果,目标3人",
308
- confirmation: null,
309
- overrides: null
310
- });
311
-
312
- assert.equal(r.searchParams.city, "杭州");
313
- assert.equal(r.proposed_keyword, "算法");
314
- assert.deepEqual(r.missing_fields, []);
315
- assert.equal(r.has_unresolved_missing_fields, false);
316
- assert.equal(r.needs_keyword_confirmation, true);
317
- assert.match(r.screenParams.criteria, /CCF-A论文/);
318
- }
319
-
320
- function testExplicitCriteriaTextIsPreserved() {
321
- const r = parseRecruitInstruction({
322
- instruction:
323
- "关键词:算法,城市:杭州,学历:本科及以上,学校:985、211、qs100,目标人数:3人,筛选条件:必须有CCF-A论文或者会议成果,本科学校必须至少是211或者qs100院校",
324
- confirmation: {
325
- keyword_confirmed: true,
326
- keyword_value: "算法",
327
- search_params_confirmed: true
328
- },
329
- overrides: null
330
- });
331
-
332
- assert.equal(
333
- r.screenParams.criteria,
334
- "必须有CCF-A论文或者会议成果,本科学校必须至少是211或者qs100院校"
335
- );
336
- }
337
-
338
305
  function main() {
339
306
  testNeedInput();
340
307
  testExampleExtraction();
@@ -347,8 +314,6 @@ function main() {
347
314
  testDefaultsCanOnlyApplyWhenExplicitlyRequested();
348
315
  testRecentViewedFilterPromptAndNegativeOverride();
349
316
  testCriteriaConfirmationPrompt();
350
- testNaturalSentenceExtractsCityAndKeywordWithoutMissing();
351
- testExplicitCriteriaTextIsPreserved();
352
317
  // eslint-disable-next-line no-console
353
318
  console.log("parser tests passed");
354
319
  }
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ RUN_MODE_ASYNC,
7
+ RUN_STAGE_SCREEN,
8
+ RUN_STATE_COMPLETED,
9
+ RUN_STATE_QUEUED,
10
+ RUN_STATE_RUNNING,
11
+ cleanupExpiredRuns,
12
+ createRunId,
13
+ createRunStateSnapshot,
14
+ getRunsDir,
15
+ readRunState,
16
+ touchRunHeartbeat,
17
+ updateRunProgress,
18
+ updateRunState,
19
+ writeRunState
20
+ } from "./run-state.js";
21
+
22
+ function withTempHome(testFn) {
23
+ const previous = process.env.BOSS_RECRUIT_HOME;
24
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recruit-run-state-"));
25
+ process.env.BOSS_RECRUIT_HOME = tempHome;
26
+ try {
27
+ testFn(tempHome);
28
+ } finally {
29
+ if (previous === undefined) {
30
+ delete process.env.BOSS_RECRUIT_HOME;
31
+ } else {
32
+ process.env.BOSS_RECRUIT_HOME = previous;
33
+ }
34
+ fs.rmSync(tempHome, { recursive: true, force: true });
35
+ }
36
+ }
37
+
38
+ function testRunStateLifecycle() {
39
+ withTempHome(() => {
40
+ const runId = createRunId();
41
+ const queued = writeRunState(createRunStateSnapshot({
42
+ runId,
43
+ mode: RUN_MODE_ASYNC,
44
+ state: RUN_STATE_QUEUED,
45
+ stage: "preflight"
46
+ }));
47
+ assert.equal(queued.run_id, runId);
48
+ assert.equal(queued.state, RUN_STATE_QUEUED);
49
+
50
+ const running = updateRunState(runId, {
51
+ state: RUN_STATE_RUNNING,
52
+ stage: RUN_STAGE_SCREEN,
53
+ last_message: "screening in progress"
54
+ });
55
+ assert.equal(running.state, RUN_STATE_RUNNING);
56
+ assert.equal(running.stage, RUN_STAGE_SCREEN);
57
+ const heartbeatBeforeProgress = running.heartbeat_at;
58
+
59
+ const progressed = updateRunProgress(runId, {
60
+ processed: 7,
61
+ passed: 2,
62
+ skipped: 5,
63
+ greet_count: 1
64
+ });
65
+ assert.equal(progressed.progress.processed, 7);
66
+ assert.equal(progressed.progress.passed, 2);
67
+ assert.equal(progressed.progress.skipped, 5);
68
+ assert.equal(progressed.progress.greet_count, 1);
69
+ assert.equal(progressed.heartbeat_at, heartbeatBeforeProgress);
70
+
71
+ const heartbeated = touchRunHeartbeat(runId, "still running");
72
+ assert.equal(heartbeated.last_message, "still running");
73
+ assert.equal(Date.parse(heartbeated.heartbeat_at) >= Date.parse(heartbeatBeforeProgress), true);
74
+
75
+ const completed = updateRunState(runId, {
76
+ state: RUN_STATE_COMPLETED,
77
+ stage: "finalize",
78
+ result: {
79
+ status: "COMPLETED",
80
+ result: {
81
+ processed_count: 7
82
+ }
83
+ }
84
+ });
85
+ assert.equal(completed.state, RUN_STATE_COMPLETED);
86
+ assert.equal(completed.result.status, "COMPLETED");
87
+
88
+ const reloaded = readRunState(runId);
89
+ assert.equal(reloaded.state, RUN_STATE_COMPLETED);
90
+ assert.equal(reloaded.progress.processed, 7);
91
+ });
92
+ }
93
+
94
+ function testRunStateCleanup() {
95
+ withTempHome(() => {
96
+ const runId = createRunId();
97
+ writeRunState(createRunStateSnapshot({ runId, mode: RUN_MODE_ASYNC }));
98
+ const runFile = path.join(getRunsDir(), `${runId}.json`);
99
+ const oldSeconds = Math.floor((Date.now() - 3 * 24 * 60 * 60 * 1000) / 1000);
100
+ fs.utimesSync(runFile, oldSeconds, oldSeconds);
101
+
102
+ const cleaned = cleanupExpiredRuns(1000);
103
+ assert.equal(cleaned.removed.some((item) => item.endsWith(`${runId}.json`)), true);
104
+ assert.equal(fs.existsSync(runFile), false);
105
+ });
106
+ }
107
+
108
+ function main() {
109
+ testRunStateLifecycle();
110
+ testRunStateCleanup();
111
+ console.log("run-state tests passed");
112
+ }
113
+
114
+ main();
115
+