@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
|
@@ -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();
|
|
@@ -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
|
+
|