@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.
- package/README.md +2 -2
- package/bin/boss-recommend-mcp.js +0 -0
- package/config/screening-config.example.json +1 -1
- package/package.json +120 -120
- package/src/cli.js +115 -48
- package/src/core/browser/index.js +629 -23
- package/src/core/run/index.js +310 -310
- package/src/domains/recommend/detail.js +544 -544
- package/src/domains/recommend/run-service.js +1235 -1235
- package/src/recommend-mcp.js +1701 -1701
- package/src/run-state.js +358 -358
package/src/run-state.js
CHANGED
|
@@ -1,358 +1,358 @@
|
|
|
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_PAUSED = "paused";
|
|
12
|
-
export const RUN_STATE_COMPLETED = "completed";
|
|
13
|
-
export const RUN_STATE_FAILED = "failed";
|
|
14
|
-
export const RUN_STATE_CANCELED = "canceled";
|
|
15
|
-
|
|
16
|
-
export const RUN_STAGE_PREFLIGHT = "preflight";
|
|
17
|
-
export const RUN_STAGE_PAGE_READY = "page_ready";
|
|
18
|
-
export const RUN_STAGE_JOB_LIST = "job_list";
|
|
19
|
-
export const RUN_STAGE_SEARCH = "search";
|
|
20
|
-
export const RUN_STAGE_SCREEN = "screen";
|
|
21
|
-
export const RUN_STAGE_CHAT_FOLLOWUP = "chat_followup";
|
|
22
|
-
export const RUN_STAGE_FINALIZE = "finalize";
|
|
23
|
-
|
|
24
|
-
const DEFAULT_HEARTBEAT_INTERVAL_MS = 120_000;
|
|
25
|
-
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
26
|
-
|
|
27
|
-
const VALID_RUN_MODES = new Set([RUN_MODE_SYNC, RUN_MODE_ASYNC]);
|
|
28
|
-
const VALID_RUN_STATES = new Set([
|
|
29
|
-
RUN_STATE_QUEUED,
|
|
30
|
-
RUN_STATE_RUNNING,
|
|
31
|
-
RUN_STATE_PAUSED,
|
|
32
|
-
RUN_STATE_COMPLETED,
|
|
33
|
-
RUN_STATE_FAILED,
|
|
34
|
-
RUN_STATE_CANCELED
|
|
35
|
-
]);
|
|
36
|
-
const VALID_RUN_STAGES = new Set([
|
|
37
|
-
RUN_STAGE_PREFLIGHT,
|
|
38
|
-
RUN_STAGE_PAGE_READY,
|
|
39
|
-
RUN_STAGE_JOB_LIST,
|
|
40
|
-
RUN_STAGE_SEARCH,
|
|
41
|
-
RUN_STAGE_SCREEN,
|
|
42
|
-
RUN_STAGE_CHAT_FOLLOWUP,
|
|
43
|
-
RUN_STAGE_FINALIZE
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
function toIsoNow() {
|
|
47
|
-
return new Date().toISOString();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function parsePositiveInteger(raw, fallback) {
|
|
51
|
-
const value = Number.parseInt(String(raw || ""), 10);
|
|
52
|
-
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function getRunHeartbeatIntervalMs() {
|
|
56
|
-
return parsePositiveInteger(process.env.BOSS_RECOMMEND_RUN_HEARTBEAT_MS, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function getRunRetentionMs() {
|
|
60
|
-
return parsePositiveInteger(process.env.BOSS_RECOMMEND_RUN_RETENTION_MS, DEFAULT_RETENTION_MS);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function getStateHome() {
|
|
64
|
-
return process.env.BOSS_RECOMMEND_HOME
|
|
65
|
-
? path.resolve(process.env.BOSS_RECOMMEND_HOME)
|
|
66
|
-
: path.join(os.homedir(), ".boss-recommend-mcp");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function getRunsDir() {
|
|
70
|
-
return path.join(getStateHome(), "runs");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function ensureRunsDir() {
|
|
74
|
-
fs.mkdirSync(getRunsDir(), { recursive: true });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function normalizeRunId(runId) {
|
|
78
|
-
return String(runId || "").trim();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function getRunStatePath(runId) {
|
|
82
|
-
const normalized = normalizeRunId(runId);
|
|
83
|
-
if (!normalized || normalized.includes("/") || normalized.includes("\\")) {
|
|
84
|
-
throw new Error("Invalid run_id");
|
|
85
|
-
}
|
|
86
|
-
return path.join(getRunsDir(), `${normalized}.json`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function safeReadJson(filePath) {
|
|
90
|
-
try {
|
|
91
|
-
if (!fs.existsSync(filePath)) return null;
|
|
92
|
-
const raw = fs.readFileSync(filePath, "utf8");
|
|
93
|
-
const parsed = JSON.parse(raw);
|
|
94
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
95
|
-
} catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function safeWriteJson(filePath, payload) {
|
|
101
|
-
ensureRunsDir();
|
|
102
|
-
const tempPath = `${filePath}.tmp`;
|
|
103
|
-
fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
104
|
-
fs.renameSync(tempPath, filePath);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function defaultProgress(progress = {}) {
|
|
108
|
-
return {
|
|
109
|
-
processed: Number.isInteger(progress.processed) && progress.processed >= 0 ? progress.processed : 0,
|
|
110
|
-
passed: Number.isInteger(progress.passed) && progress.passed >= 0 ? progress.passed : 0,
|
|
111
|
-
skipped: Number.isInteger(progress.skipped) && progress.skipped >= 0 ? progress.skipped : 0,
|
|
112
|
-
greet_count: Number.isInteger(progress.greet_count) && progress.greet_count >= 0 ? progress.greet_count : 0
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function normalizeRunMode(mode) {
|
|
117
|
-
const normalized = String(mode || "").trim().toLowerCase();
|
|
118
|
-
return VALID_RUN_MODES.has(normalized) ? normalized : RUN_MODE_SYNC;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function normalizeRunState(state) {
|
|
122
|
-
const normalized = String(state || "").trim().toLowerCase();
|
|
123
|
-
return VALID_RUN_STATES.has(normalized) ? normalized : RUN_STATE_QUEUED;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function normalizeRunStage(stage) {
|
|
127
|
-
const normalized = String(stage || "").trim().toLowerCase();
|
|
128
|
-
return VALID_RUN_STAGES.has(normalized) ? normalized : RUN_STAGE_PREFLIGHT;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function normalizeMessage(message) {
|
|
132
|
-
const normalized = String(message || "").replace(/\s+/g, " ").trim();
|
|
133
|
-
return normalized || null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function clonePlainObject(value) {
|
|
137
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(JSON.stringify(value));
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function defaultContext(context = null) {
|
|
146
|
-
return clonePlainObject(context);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function defaultControl(control = {}) {
|
|
150
|
-
return {
|
|
151
|
-
pause_requested: control?.pause_requested === true,
|
|
152
|
-
pause_requested_at: normalizeMessage(control?.pause_requested_at || ""),
|
|
153
|
-
pause_requested_by: normalizeMessage(control?.pause_requested_by || ""),
|
|
154
|
-
cancel_requested: control?.cancel_requested === true
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function defaultResume(resume = {}) {
|
|
159
|
-
return {
|
|
160
|
-
checkpoint_path: normalizeMessage(resume?.checkpoint_path || ""),
|
|
161
|
-
pause_control_path: normalizeMessage(resume?.pause_control_path || ""),
|
|
162
|
-
output_csv: normalizeMessage(resume?.output_csv || ""),
|
|
163
|
-
worker_stdout_path: normalizeMessage(resume?.worker_stdout_path || ""),
|
|
164
|
-
worker_stderr_path: normalizeMessage(resume?.worker_stderr_path || ""),
|
|
165
|
-
follow_up_phase: normalizeMessage(resume?.follow_up_phase || ""),
|
|
166
|
-
chat_run_id: normalizeMessage(resume?.chat_run_id || ""),
|
|
167
|
-
chat_state: normalizeMessage(resume?.chat_state || ""),
|
|
168
|
-
resume_count: Number.isInteger(resume?.resume_count) && resume.resume_count >= 0 ? resume.resume_count : 0,
|
|
169
|
-
last_resumed_at: normalizeMessage(resume?.last_resumed_at || ""),
|
|
170
|
-
last_paused_at: normalizeMessage(resume?.last_paused_at || "")
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function createRunId() {
|
|
175
|
-
if (typeof crypto.randomUUID === "function") {
|
|
176
|
-
return crypto.randomUUID();
|
|
177
|
-
}
|
|
178
|
-
return crypto.randomBytes(16).toString("hex");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function createRunStateSnapshot({
|
|
182
|
-
runId,
|
|
183
|
-
mode = RUN_MODE_SYNC,
|
|
184
|
-
state = RUN_STATE_QUEUED,
|
|
185
|
-
stage = RUN_STAGE_PREFLIGHT,
|
|
186
|
-
pid = process.pid,
|
|
187
|
-
lastMessage = null,
|
|
188
|
-
context = null,
|
|
189
|
-
control = null,
|
|
190
|
-
resume = null
|
|
191
|
-
} = {}) {
|
|
192
|
-
const now = toIsoNow();
|
|
193
|
-
return {
|
|
194
|
-
run_id: normalizeRunId(runId) || createRunId(),
|
|
195
|
-
mode: normalizeRunMode(mode),
|
|
196
|
-
state: normalizeRunState(state),
|
|
197
|
-
stage: normalizeRunStage(stage),
|
|
198
|
-
started_at: now,
|
|
199
|
-
updated_at: now,
|
|
200
|
-
heartbeat_at: now,
|
|
201
|
-
pid: Number.isInteger(pid) && pid > 0 ? pid : process.pid,
|
|
202
|
-
progress: defaultProgress(),
|
|
203
|
-
last_message: normalizeMessage(lastMessage),
|
|
204
|
-
context: defaultContext(context),
|
|
205
|
-
control: defaultControl(control),
|
|
206
|
-
resume: defaultResume(resume),
|
|
207
|
-
error: null,
|
|
208
|
-
result: null
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function writeRunState(snapshot) {
|
|
213
|
-
const runId = normalizeRunId(snapshot?.run_id);
|
|
214
|
-
if (!runId) {
|
|
215
|
-
throw new Error("run_id is required");
|
|
216
|
-
}
|
|
217
|
-
const now = toIsoNow();
|
|
218
|
-
const payload = {
|
|
219
|
-
run_id: runId,
|
|
220
|
-
mode: normalizeRunMode(snapshot.mode),
|
|
221
|
-
state: normalizeRunState(snapshot.state),
|
|
222
|
-
stage: normalizeRunStage(snapshot.stage),
|
|
223
|
-
started_at: String(snapshot.started_at || now),
|
|
224
|
-
updated_at: String(snapshot.updated_at || now),
|
|
225
|
-
heartbeat_at: String(snapshot.heartbeat_at || now),
|
|
226
|
-
pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid,
|
|
227
|
-
progress: defaultProgress(snapshot.progress),
|
|
228
|
-
last_message: normalizeMessage(snapshot.last_message),
|
|
229
|
-
context: defaultContext(snapshot.context),
|
|
230
|
-
control: defaultControl(snapshot.control),
|
|
231
|
-
resume: defaultResume(snapshot.resume),
|
|
232
|
-
error: snapshot.error || null,
|
|
233
|
-
result: snapshot.result || null
|
|
234
|
-
};
|
|
235
|
-
safeWriteJson(getRunStatePath(runId), payload);
|
|
236
|
-
return payload;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function readRunState(runId) {
|
|
240
|
-
const payload = safeReadJson(getRunStatePath(runId));
|
|
241
|
-
if (!payload) return null;
|
|
242
|
-
return {
|
|
243
|
-
run_id: normalizeRunId(payload.run_id),
|
|
244
|
-
mode: normalizeRunMode(payload.mode),
|
|
245
|
-
state: normalizeRunState(payload.state),
|
|
246
|
-
stage: normalizeRunStage(payload.stage),
|
|
247
|
-
started_at: String(payload.started_at || ""),
|
|
248
|
-
updated_at: String(payload.updated_at || ""),
|
|
249
|
-
heartbeat_at: String(payload.heartbeat_at || ""),
|
|
250
|
-
pid: Number.isInteger(payload.pid) && payload.pid > 0 ? payload.pid : process.pid,
|
|
251
|
-
progress: defaultProgress(payload.progress),
|
|
252
|
-
last_message: normalizeMessage(payload.last_message),
|
|
253
|
-
context: defaultContext(payload.context),
|
|
254
|
-
control: defaultControl(payload.control),
|
|
255
|
-
resume: defaultResume(payload.resume),
|
|
256
|
-
error: payload.error || null,
|
|
257
|
-
result: payload.result || null
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function updateRunState(runId, updater) {
|
|
262
|
-
const current = readRunState(runId);
|
|
263
|
-
if (!current) return null;
|
|
264
|
-
const patch = typeof updater === "function" ? updater({ ...current }) : updater;
|
|
265
|
-
if (!patch || typeof patch !== "object") {
|
|
266
|
-
return current;
|
|
267
|
-
}
|
|
268
|
-
const now = toIsoNow();
|
|
269
|
-
const next = {
|
|
270
|
-
...current,
|
|
271
|
-
...patch,
|
|
272
|
-
run_id: current.run_id,
|
|
273
|
-
mode: normalizeRunMode(patch.mode ?? current.mode),
|
|
274
|
-
state: normalizeRunState(patch.state ?? current.state),
|
|
275
|
-
stage: normalizeRunStage(patch.stage ?? current.stage),
|
|
276
|
-
progress: defaultProgress({
|
|
277
|
-
...current.progress,
|
|
278
|
-
...(patch.progress || {})
|
|
279
|
-
}),
|
|
280
|
-
context: Object.prototype.hasOwnProperty.call(patch, "context")
|
|
281
|
-
? defaultContext(patch.context)
|
|
282
|
-
: current.context,
|
|
283
|
-
control: defaultControl({
|
|
284
|
-
...current.control,
|
|
285
|
-
...(patch.control || {})
|
|
286
|
-
}),
|
|
287
|
-
resume: defaultResume({
|
|
288
|
-
...current.resume,
|
|
289
|
-
...(patch.resume || {})
|
|
290
|
-
}),
|
|
291
|
-
last_message: normalizeMessage(
|
|
292
|
-
Object.prototype.hasOwnProperty.call(patch, "last_message")
|
|
293
|
-
? patch.last_message
|
|
294
|
-
: current.last_message
|
|
295
|
-
),
|
|
296
|
-
updated_at: now,
|
|
297
|
-
heartbeat_at: String(
|
|
298
|
-
Object.prototype.hasOwnProperty.call(patch, "heartbeat_at")
|
|
299
|
-
? (patch.heartbeat_at || now)
|
|
300
|
-
: current.heartbeat_at
|
|
301
|
-
)
|
|
302
|
-
};
|
|
303
|
-
return writeRunState(next);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
export function touchRunHeartbeat(runId, message = null) {
|
|
307
|
-
return updateRunState(runId, (current) => ({
|
|
308
|
-
heartbeat_at: toIsoNow(),
|
|
309
|
-
last_message: message ?? current.last_message
|
|
310
|
-
}));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export function updateRunProgress(runId, progressPatch = {}, message = null) {
|
|
314
|
-
const patch = {
|
|
315
|
-
progress: {}
|
|
316
|
-
};
|
|
317
|
-
if (Number.isInteger(progressPatch.processed) && progressPatch.processed >= 0) {
|
|
318
|
-
patch.progress.processed = progressPatch.processed;
|
|
319
|
-
}
|
|
320
|
-
if (Number.isInteger(progressPatch.passed) && progressPatch.passed >= 0) {
|
|
321
|
-
patch.progress.passed = progressPatch.passed;
|
|
322
|
-
}
|
|
323
|
-
if (Number.isInteger(progressPatch.skipped) && progressPatch.skipped >= 0) {
|
|
324
|
-
patch.progress.skipped = progressPatch.skipped;
|
|
325
|
-
}
|
|
326
|
-
if (Number.isInteger(progressPatch.greet_count) && progressPatch.greet_count >= 0) {
|
|
327
|
-
patch.progress.greet_count = progressPatch.greet_count;
|
|
328
|
-
}
|
|
329
|
-
if (message !== null) {
|
|
330
|
-
patch.last_message = message;
|
|
331
|
-
}
|
|
332
|
-
return updateRunState(runId, patch);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function cleanupExpiredRuns(retentionMs = getRunRetentionMs()) {
|
|
336
|
-
ensureRunsDir();
|
|
337
|
-
const removed = [];
|
|
338
|
-
const failed = [];
|
|
339
|
-
const now = Date.now();
|
|
340
|
-
const entries = fs.readdirSync(getRunsDir(), { withFileTypes: true });
|
|
341
|
-
for (const entry of entries) {
|
|
342
|
-
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
343
|
-
const filePath = path.join(getRunsDir(), entry.name);
|
|
344
|
-
try {
|
|
345
|
-
const stat = fs.statSync(filePath);
|
|
346
|
-
const age = now - Number(stat.mtimeMs || 0);
|
|
347
|
-
if (age < retentionMs) continue;
|
|
348
|
-
fs.unlinkSync(filePath);
|
|
349
|
-
removed.push(filePath);
|
|
350
|
-
} catch (error) {
|
|
351
|
-
failed.push({
|
|
352
|
-
file: filePath,
|
|
353
|
-
reason: error.message || String(error)
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return { removed, failed };
|
|
358
|
-
}
|
|
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_PAUSED = "paused";
|
|
12
|
+
export const RUN_STATE_COMPLETED = "completed";
|
|
13
|
+
export const RUN_STATE_FAILED = "failed";
|
|
14
|
+
export const RUN_STATE_CANCELED = "canceled";
|
|
15
|
+
|
|
16
|
+
export const RUN_STAGE_PREFLIGHT = "preflight";
|
|
17
|
+
export const RUN_STAGE_PAGE_READY = "page_ready";
|
|
18
|
+
export const RUN_STAGE_JOB_LIST = "job_list";
|
|
19
|
+
export const RUN_STAGE_SEARCH = "search";
|
|
20
|
+
export const RUN_STAGE_SCREEN = "screen";
|
|
21
|
+
export const RUN_STAGE_CHAT_FOLLOWUP = "chat_followup";
|
|
22
|
+
export const RUN_STAGE_FINALIZE = "finalize";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 120_000;
|
|
25
|
+
const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const VALID_RUN_MODES = new Set([RUN_MODE_SYNC, RUN_MODE_ASYNC]);
|
|
28
|
+
const VALID_RUN_STATES = new Set([
|
|
29
|
+
RUN_STATE_QUEUED,
|
|
30
|
+
RUN_STATE_RUNNING,
|
|
31
|
+
RUN_STATE_PAUSED,
|
|
32
|
+
RUN_STATE_COMPLETED,
|
|
33
|
+
RUN_STATE_FAILED,
|
|
34
|
+
RUN_STATE_CANCELED
|
|
35
|
+
]);
|
|
36
|
+
const VALID_RUN_STAGES = new Set([
|
|
37
|
+
RUN_STAGE_PREFLIGHT,
|
|
38
|
+
RUN_STAGE_PAGE_READY,
|
|
39
|
+
RUN_STAGE_JOB_LIST,
|
|
40
|
+
RUN_STAGE_SEARCH,
|
|
41
|
+
RUN_STAGE_SCREEN,
|
|
42
|
+
RUN_STAGE_CHAT_FOLLOWUP,
|
|
43
|
+
RUN_STAGE_FINALIZE
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function toIsoNow() {
|
|
47
|
+
return new Date().toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parsePositiveInteger(raw, fallback) {
|
|
51
|
+
const value = Number.parseInt(String(raw || ""), 10);
|
|
52
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getRunHeartbeatIntervalMs() {
|
|
56
|
+
return parsePositiveInteger(process.env.BOSS_RECOMMEND_RUN_HEARTBEAT_MS, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRunRetentionMs() {
|
|
60
|
+
return parsePositiveInteger(process.env.BOSS_RECOMMEND_RUN_RETENTION_MS, DEFAULT_RETENTION_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getStateHome() {
|
|
64
|
+
return process.env.BOSS_RECOMMEND_HOME
|
|
65
|
+
? path.resolve(process.env.BOSS_RECOMMEND_HOME)
|
|
66
|
+
: path.join(os.homedir(), ".boss-recommend-mcp");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getRunsDir() {
|
|
70
|
+
return path.join(getStateHome(), "runs");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ensureRunsDir() {
|
|
74
|
+
fs.mkdirSync(getRunsDir(), { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeRunId(runId) {
|
|
78
|
+
return String(runId || "").trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getRunStatePath(runId) {
|
|
82
|
+
const normalized = normalizeRunId(runId);
|
|
83
|
+
if (!normalized || normalized.includes("/") || normalized.includes("\\")) {
|
|
84
|
+
throw new Error("Invalid run_id");
|
|
85
|
+
}
|
|
86
|
+
return path.join(getRunsDir(), `${normalized}.json`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeReadJson(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(filePath)) return null;
|
|
92
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function safeWriteJson(filePath, payload) {
|
|
101
|
+
ensureRunsDir();
|
|
102
|
+
const tempPath = `${filePath}.tmp`;
|
|
103
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
104
|
+
fs.renameSync(tempPath, filePath);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defaultProgress(progress = {}) {
|
|
108
|
+
return {
|
|
109
|
+
processed: Number.isInteger(progress.processed) && progress.processed >= 0 ? progress.processed : 0,
|
|
110
|
+
passed: Number.isInteger(progress.passed) && progress.passed >= 0 ? progress.passed : 0,
|
|
111
|
+
skipped: Number.isInteger(progress.skipped) && progress.skipped >= 0 ? progress.skipped : 0,
|
|
112
|
+
greet_count: Number.isInteger(progress.greet_count) && progress.greet_count >= 0 ? progress.greet_count : 0
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeRunMode(mode) {
|
|
117
|
+
const normalized = String(mode || "").trim().toLowerCase();
|
|
118
|
+
return VALID_RUN_MODES.has(normalized) ? normalized : RUN_MODE_SYNC;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeRunState(state) {
|
|
122
|
+
const normalized = String(state || "").trim().toLowerCase();
|
|
123
|
+
return VALID_RUN_STATES.has(normalized) ? normalized : RUN_STATE_QUEUED;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeRunStage(stage) {
|
|
127
|
+
const normalized = String(stage || "").trim().toLowerCase();
|
|
128
|
+
return VALID_RUN_STAGES.has(normalized) ? normalized : RUN_STAGE_PREFLIGHT;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeMessage(message) {
|
|
132
|
+
const normalized = String(message || "").replace(/\s+/g, " ").trim();
|
|
133
|
+
return normalized || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clonePlainObject(value) {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(JSON.stringify(value));
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function defaultContext(context = null) {
|
|
146
|
+
return clonePlainObject(context);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function defaultControl(control = {}) {
|
|
150
|
+
return {
|
|
151
|
+
pause_requested: control?.pause_requested === true,
|
|
152
|
+
pause_requested_at: normalizeMessage(control?.pause_requested_at || ""),
|
|
153
|
+
pause_requested_by: normalizeMessage(control?.pause_requested_by || ""),
|
|
154
|
+
cancel_requested: control?.cancel_requested === true
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function defaultResume(resume = {}) {
|
|
159
|
+
return {
|
|
160
|
+
checkpoint_path: normalizeMessage(resume?.checkpoint_path || ""),
|
|
161
|
+
pause_control_path: normalizeMessage(resume?.pause_control_path || ""),
|
|
162
|
+
output_csv: normalizeMessage(resume?.output_csv || ""),
|
|
163
|
+
worker_stdout_path: normalizeMessage(resume?.worker_stdout_path || ""),
|
|
164
|
+
worker_stderr_path: normalizeMessage(resume?.worker_stderr_path || ""),
|
|
165
|
+
follow_up_phase: normalizeMessage(resume?.follow_up_phase || ""),
|
|
166
|
+
chat_run_id: normalizeMessage(resume?.chat_run_id || ""),
|
|
167
|
+
chat_state: normalizeMessage(resume?.chat_state || ""),
|
|
168
|
+
resume_count: Number.isInteger(resume?.resume_count) && resume.resume_count >= 0 ? resume.resume_count : 0,
|
|
169
|
+
last_resumed_at: normalizeMessage(resume?.last_resumed_at || ""),
|
|
170
|
+
last_paused_at: normalizeMessage(resume?.last_paused_at || "")
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createRunId() {
|
|
175
|
+
if (typeof crypto.randomUUID === "function") {
|
|
176
|
+
return crypto.randomUUID();
|
|
177
|
+
}
|
|
178
|
+
return crypto.randomBytes(16).toString("hex");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createRunStateSnapshot({
|
|
182
|
+
runId,
|
|
183
|
+
mode = RUN_MODE_SYNC,
|
|
184
|
+
state = RUN_STATE_QUEUED,
|
|
185
|
+
stage = RUN_STAGE_PREFLIGHT,
|
|
186
|
+
pid = process.pid,
|
|
187
|
+
lastMessage = null,
|
|
188
|
+
context = null,
|
|
189
|
+
control = null,
|
|
190
|
+
resume = null
|
|
191
|
+
} = {}) {
|
|
192
|
+
const now = toIsoNow();
|
|
193
|
+
return {
|
|
194
|
+
run_id: normalizeRunId(runId) || createRunId(),
|
|
195
|
+
mode: normalizeRunMode(mode),
|
|
196
|
+
state: normalizeRunState(state),
|
|
197
|
+
stage: normalizeRunStage(stage),
|
|
198
|
+
started_at: now,
|
|
199
|
+
updated_at: now,
|
|
200
|
+
heartbeat_at: now,
|
|
201
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : process.pid,
|
|
202
|
+
progress: defaultProgress(),
|
|
203
|
+
last_message: normalizeMessage(lastMessage),
|
|
204
|
+
context: defaultContext(context),
|
|
205
|
+
control: defaultControl(control),
|
|
206
|
+
resume: defaultResume(resume),
|
|
207
|
+
error: null,
|
|
208
|
+
result: null
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function writeRunState(snapshot) {
|
|
213
|
+
const runId = normalizeRunId(snapshot?.run_id);
|
|
214
|
+
if (!runId) {
|
|
215
|
+
throw new Error("run_id is required");
|
|
216
|
+
}
|
|
217
|
+
const now = toIsoNow();
|
|
218
|
+
const payload = {
|
|
219
|
+
run_id: runId,
|
|
220
|
+
mode: normalizeRunMode(snapshot.mode),
|
|
221
|
+
state: normalizeRunState(snapshot.state),
|
|
222
|
+
stage: normalizeRunStage(snapshot.stage),
|
|
223
|
+
started_at: String(snapshot.started_at || now),
|
|
224
|
+
updated_at: String(snapshot.updated_at || now),
|
|
225
|
+
heartbeat_at: String(snapshot.heartbeat_at || now),
|
|
226
|
+
pid: Number.isInteger(snapshot.pid) && snapshot.pid > 0 ? snapshot.pid : process.pid,
|
|
227
|
+
progress: defaultProgress(snapshot.progress),
|
|
228
|
+
last_message: normalizeMessage(snapshot.last_message),
|
|
229
|
+
context: defaultContext(snapshot.context),
|
|
230
|
+
control: defaultControl(snapshot.control),
|
|
231
|
+
resume: defaultResume(snapshot.resume),
|
|
232
|
+
error: snapshot.error || null,
|
|
233
|
+
result: snapshot.result || null
|
|
234
|
+
};
|
|
235
|
+
safeWriteJson(getRunStatePath(runId), payload);
|
|
236
|
+
return payload;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function readRunState(runId) {
|
|
240
|
+
const payload = safeReadJson(getRunStatePath(runId));
|
|
241
|
+
if (!payload) return null;
|
|
242
|
+
return {
|
|
243
|
+
run_id: normalizeRunId(payload.run_id),
|
|
244
|
+
mode: normalizeRunMode(payload.mode),
|
|
245
|
+
state: normalizeRunState(payload.state),
|
|
246
|
+
stage: normalizeRunStage(payload.stage),
|
|
247
|
+
started_at: String(payload.started_at || ""),
|
|
248
|
+
updated_at: String(payload.updated_at || ""),
|
|
249
|
+
heartbeat_at: String(payload.heartbeat_at || ""),
|
|
250
|
+
pid: Number.isInteger(payload.pid) && payload.pid > 0 ? payload.pid : process.pid,
|
|
251
|
+
progress: defaultProgress(payload.progress),
|
|
252
|
+
last_message: normalizeMessage(payload.last_message),
|
|
253
|
+
context: defaultContext(payload.context),
|
|
254
|
+
control: defaultControl(payload.control),
|
|
255
|
+
resume: defaultResume(payload.resume),
|
|
256
|
+
error: payload.error || null,
|
|
257
|
+
result: payload.result || null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function updateRunState(runId, updater) {
|
|
262
|
+
const current = readRunState(runId);
|
|
263
|
+
if (!current) return null;
|
|
264
|
+
const patch = typeof updater === "function" ? updater({ ...current }) : updater;
|
|
265
|
+
if (!patch || typeof patch !== "object") {
|
|
266
|
+
return current;
|
|
267
|
+
}
|
|
268
|
+
const now = toIsoNow();
|
|
269
|
+
const next = {
|
|
270
|
+
...current,
|
|
271
|
+
...patch,
|
|
272
|
+
run_id: current.run_id,
|
|
273
|
+
mode: normalizeRunMode(patch.mode ?? current.mode),
|
|
274
|
+
state: normalizeRunState(patch.state ?? current.state),
|
|
275
|
+
stage: normalizeRunStage(patch.stage ?? current.stage),
|
|
276
|
+
progress: defaultProgress({
|
|
277
|
+
...current.progress,
|
|
278
|
+
...(patch.progress || {})
|
|
279
|
+
}),
|
|
280
|
+
context: Object.prototype.hasOwnProperty.call(patch, "context")
|
|
281
|
+
? defaultContext(patch.context)
|
|
282
|
+
: current.context,
|
|
283
|
+
control: defaultControl({
|
|
284
|
+
...current.control,
|
|
285
|
+
...(patch.control || {})
|
|
286
|
+
}),
|
|
287
|
+
resume: defaultResume({
|
|
288
|
+
...current.resume,
|
|
289
|
+
...(patch.resume || {})
|
|
290
|
+
}),
|
|
291
|
+
last_message: normalizeMessage(
|
|
292
|
+
Object.prototype.hasOwnProperty.call(patch, "last_message")
|
|
293
|
+
? patch.last_message
|
|
294
|
+
: current.last_message
|
|
295
|
+
),
|
|
296
|
+
updated_at: now,
|
|
297
|
+
heartbeat_at: String(
|
|
298
|
+
Object.prototype.hasOwnProperty.call(patch, "heartbeat_at")
|
|
299
|
+
? (patch.heartbeat_at || now)
|
|
300
|
+
: current.heartbeat_at
|
|
301
|
+
)
|
|
302
|
+
};
|
|
303
|
+
return writeRunState(next);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function touchRunHeartbeat(runId, message = null) {
|
|
307
|
+
return updateRunState(runId, (current) => ({
|
|
308
|
+
heartbeat_at: toIsoNow(),
|
|
309
|
+
last_message: message ?? current.last_message
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function updateRunProgress(runId, progressPatch = {}, message = null) {
|
|
314
|
+
const patch = {
|
|
315
|
+
progress: {}
|
|
316
|
+
};
|
|
317
|
+
if (Number.isInteger(progressPatch.processed) && progressPatch.processed >= 0) {
|
|
318
|
+
patch.progress.processed = progressPatch.processed;
|
|
319
|
+
}
|
|
320
|
+
if (Number.isInteger(progressPatch.passed) && progressPatch.passed >= 0) {
|
|
321
|
+
patch.progress.passed = progressPatch.passed;
|
|
322
|
+
}
|
|
323
|
+
if (Number.isInteger(progressPatch.skipped) && progressPatch.skipped >= 0) {
|
|
324
|
+
patch.progress.skipped = progressPatch.skipped;
|
|
325
|
+
}
|
|
326
|
+
if (Number.isInteger(progressPatch.greet_count) && progressPatch.greet_count >= 0) {
|
|
327
|
+
patch.progress.greet_count = progressPatch.greet_count;
|
|
328
|
+
}
|
|
329
|
+
if (message !== null) {
|
|
330
|
+
patch.last_message = message;
|
|
331
|
+
}
|
|
332
|
+
return updateRunState(runId, patch);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function cleanupExpiredRuns(retentionMs = getRunRetentionMs()) {
|
|
336
|
+
ensureRunsDir();
|
|
337
|
+
const removed = [];
|
|
338
|
+
const failed = [];
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const entries = fs.readdirSync(getRunsDir(), { withFileTypes: true });
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
343
|
+
const filePath = path.join(getRunsDir(), entry.name);
|
|
344
|
+
try {
|
|
345
|
+
const stat = fs.statSync(filePath);
|
|
346
|
+
const age = now - Number(stat.mtimeMs || 0);
|
|
347
|
+
if (age < retentionMs) continue;
|
|
348
|
+
fs.unlinkSync(filePath);
|
|
349
|
+
removed.push(filePath);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
failed.push({
|
|
352
|
+
file: filePath,
|
|
353
|
+
reason: error.message || String(error)
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { removed, failed };
|
|
358
|
+
}
|