@reconcrap/boss-recommend-mcp 2.1.1 → 2.1.3

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,482 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getStateHome } from "./run-state.js";
7
+ import {
8
+ getRecommendPipelineRunTool,
9
+ prepareRecommendPipelineRunTool,
10
+ startRecommendPipelineRunTool
11
+ } from "./recommend-mcp.js";
12
+
13
+ const SCHEDULE_WORKER_FLAG = "--schedule-worker";
14
+ const SCHEDULE_ID_FLAG = "--schedule-id";
15
+ const TERMINAL_SCHEDULE_STATES = new Set(["completed", "failed", "canceled"]);
16
+ const TERMINAL_RUN_STATES = new Set(["completed", "failed", "canceled"]);
17
+
18
+ let spawnProcessImpl = spawn;
19
+
20
+ function normalizeText(value) {
21
+ return String(value || "").replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function clonePlain(value, fallback = null) {
25
+ try {
26
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ function nowIso() {
33
+ return new Date().toISOString();
34
+ }
35
+
36
+ function sleep(ms) {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ function parseNonNegativeNumber(raw, fallback = null) {
41
+ if (raw === undefined || raw === null || raw === "") return fallback;
42
+ const parsed = Number(raw);
43
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
44
+ }
45
+
46
+ function parseRunAt(args = {}) {
47
+ const direct = normalizeText(args.schedule_run_at || args.scheduleRunAt || args.run_at || args.runAt);
48
+ if (direct) {
49
+ const timestamp = Date.parse(direct);
50
+ if (!Number.isFinite(timestamp)) {
51
+ return {
52
+ ok: false,
53
+ error: `Invalid schedule_run_at: ${direct}`
54
+ };
55
+ }
56
+ return {
57
+ ok: true,
58
+ runAtMs: timestamp,
59
+ source: "schedule_run_at"
60
+ };
61
+ }
62
+
63
+ const delaySeconds = parseNonNegativeNumber(args.schedule_delay_seconds ?? args.scheduleDelaySeconds, null);
64
+ if (delaySeconds !== null) {
65
+ return {
66
+ ok: true,
67
+ runAtMs: Date.now() + Math.round(delaySeconds * 1000),
68
+ source: "schedule_delay_seconds"
69
+ };
70
+ }
71
+
72
+ const delayMinutes = parseNonNegativeNumber(args.schedule_delay_minutes ?? args.scheduleDelayMinutes, null);
73
+ if (delayMinutes !== null) {
74
+ return {
75
+ ok: true,
76
+ runAtMs: Date.now() + Math.round(delayMinutes * 60 * 1000),
77
+ source: "schedule_delay_minutes"
78
+ };
79
+ }
80
+
81
+ return {
82
+ ok: false,
83
+ error: "schedule_run_at or schedule_delay_minutes/schedule_delay_seconds is required"
84
+ };
85
+ }
86
+
87
+ function safeIdPart(value) {
88
+ return normalizeText(value).replace(/[^a-zA-Z0-9_.-]/g, "_");
89
+ }
90
+
91
+ function createScheduleId(raw = "") {
92
+ const requested = safeIdPart(raw);
93
+ if (requested) return requested;
94
+ const suffix = Math.random().toString(36).slice(2, 10);
95
+ return `mcp_recommend_schedule_${Date.now().toString(36)}_${suffix}`;
96
+ }
97
+
98
+ function getSchedulesDir() {
99
+ return path.join(getStateHome(), "schedules");
100
+ }
101
+
102
+ function getScheduleArtifacts(scheduleId) {
103
+ const id = safeIdPart(scheduleId);
104
+ if (!id) throw new Error("schedule_id is required");
105
+ return {
106
+ schedule_path: path.join(getSchedulesDir(), `${id}.json`),
107
+ worker_stdout_path: path.join(getSchedulesDir(), `${id}.worker.stdout.log`),
108
+ worker_stderr_path: path.join(getSchedulesDir(), `${id}.worker.stderr.log`)
109
+ };
110
+ }
111
+
112
+ function writeJsonAtomic(filePath, payload) {
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ const tempPath = `${filePath}.tmp`;
115
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
116
+ fs.renameSync(tempPath, filePath);
117
+ return payload;
118
+ }
119
+
120
+ function readJsonFile(filePath) {
121
+ try {
122
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
123
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function readSchedule(scheduleId) {
130
+ const artifacts = getScheduleArtifacts(scheduleId);
131
+ return readJsonFile(artifacts.schedule_path);
132
+ }
133
+
134
+ function writeSchedule(scheduleId, patch) {
135
+ const artifacts = getScheduleArtifacts(scheduleId);
136
+ const current = readJsonFile(artifacts.schedule_path) || {};
137
+ return writeJsonAtomic(artifacts.schedule_path, {
138
+ ...current,
139
+ ...patch,
140
+ schedule_id: scheduleId,
141
+ updated_at: nowIso()
142
+ });
143
+ }
144
+
145
+ function isProcessAlive(pid) {
146
+ if (!Number.isInteger(pid) || pid <= 0) return false;
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ function stripScheduleArgs(args = {}) {
156
+ const cloned = clonePlain(args, {});
157
+ for (const key of [
158
+ "schedule_id",
159
+ "scheduleId",
160
+ "schedule_run_at",
161
+ "scheduleRunAt",
162
+ "run_at",
163
+ "runAt",
164
+ "schedule_delay_seconds",
165
+ "scheduleDelaySeconds",
166
+ "schedule_delay_minutes",
167
+ "scheduleDelayMinutes"
168
+ ]) {
169
+ delete cloned[key];
170
+ }
171
+ return cloned;
172
+ }
173
+
174
+ function buildFailedSchedulePayload(error, extra = {}) {
175
+ return {
176
+ status: "FAILED",
177
+ schedule_created: false,
178
+ error: {
179
+ code: "RECOMMEND_SCHEDULE_FAILED",
180
+ message: error?.message || String(error || "Unable to schedule recommend run"),
181
+ retryable: true
182
+ },
183
+ ...extra
184
+ };
185
+ }
186
+
187
+ function launchScheduleWorker(scheduleId) {
188
+ const artifacts = getScheduleArtifacts(scheduleId);
189
+ fs.mkdirSync(path.dirname(artifacts.worker_stdout_path), { recursive: true });
190
+ const stdoutFd = fs.openSync(artifacts.worker_stdout_path, "a");
191
+ const stderrFd = fs.openSync(artifacts.worker_stderr_path, "a");
192
+ let child;
193
+ try {
194
+ child = spawnProcessImpl(process.execPath, [
195
+ thisFilePath,
196
+ SCHEDULE_WORKER_FLAG,
197
+ SCHEDULE_ID_FLAG,
198
+ scheduleId
199
+ ], {
200
+ cwd: process.cwd(),
201
+ detached: true,
202
+ stdio: ["ignore", stdoutFd, stderrFd],
203
+ windowsHide: true,
204
+ env: process.env
205
+ });
206
+ } finally {
207
+ fs.closeSync(stdoutFd);
208
+ fs.closeSync(stderrFd);
209
+ }
210
+ if (typeof child?.unref === "function") child.unref();
211
+ return {
212
+ pid: child.pid || null,
213
+ stdoutPath: artifacts.worker_stdout_path,
214
+ stderrPath: artifacts.worker_stderr_path
215
+ };
216
+ }
217
+
218
+ export async function scheduleRecommendPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
219
+ const runArgs = stripScheduleArgs(args);
220
+ const prepared = prepareRecommendPipelineRunTool({ workspaceRoot, args: runArgs });
221
+ if (prepared.status !== "READY" || prepared.cron_ready !== true) {
222
+ return {
223
+ ...prepared,
224
+ status: prepared.status || "FAILED",
225
+ schedule_created: false,
226
+ cron_ready: false,
227
+ message: "Recommend schedule was not created because the run payload is not READY."
228
+ };
229
+ }
230
+
231
+ const due = parseRunAt(args);
232
+ if (!due.ok) {
233
+ return {
234
+ status: "FAILED",
235
+ schedule_created: false,
236
+ cron_ready: true,
237
+ error: {
238
+ code: "INVALID_SCHEDULE_TIME",
239
+ message: due.error,
240
+ retryable: false
241
+ },
242
+ prepare: prepared
243
+ };
244
+ }
245
+
246
+ const scheduleId = createScheduleId(args.schedule_id || args.scheduleId);
247
+ const artifacts = getScheduleArtifacts(scheduleId);
248
+ const runAtIso = new Date(due.runAtMs).toISOString();
249
+ const createdAt = nowIso();
250
+ try {
251
+ writeJsonAtomic(artifacts.schedule_path, {
252
+ schedule_id: scheduleId,
253
+ state: "scheduled",
254
+ status: "scheduled",
255
+ created_at: createdAt,
256
+ updated_at: createdAt,
257
+ run_at: runAtIso,
258
+ run_at_ms: due.runAtMs,
259
+ time_source: due.source,
260
+ workspace_root: path.resolve(workspaceRoot || process.cwd()),
261
+ args: runArgs,
262
+ prepare: prepared,
263
+ worker_stdout_path: artifacts.worker_stdout_path,
264
+ worker_stderr_path: artifacts.worker_stderr_path,
265
+ pid: null,
266
+ run_id: null,
267
+ run: null,
268
+ error: null
269
+ });
270
+ } catch (error) {
271
+ return buildFailedSchedulePayload(error, { prepare: prepared });
272
+ }
273
+
274
+ let worker;
275
+ try {
276
+ worker = launchScheduleWorker(scheduleId);
277
+ } catch (error) {
278
+ writeSchedule(scheduleId, {
279
+ state: "failed",
280
+ status: "failed",
281
+ error: {
282
+ code: "SCHEDULE_WORKER_LAUNCH_FAILED",
283
+ message: error?.message || String(error || "Unable to launch schedule worker"),
284
+ retryable: true
285
+ }
286
+ });
287
+ return buildFailedSchedulePayload(error, {
288
+ schedule_created: true,
289
+ schedule_id: scheduleId,
290
+ schedule: readSchedule(scheduleId),
291
+ prepare: prepared
292
+ });
293
+ }
294
+
295
+ const schedule = writeSchedule(scheduleId, {
296
+ state: "scheduled",
297
+ status: "scheduled",
298
+ pid: worker.pid,
299
+ worker_stdout_path: worker.stdoutPath,
300
+ worker_stderr_path: worker.stderrPath
301
+ });
302
+
303
+ return {
304
+ status: "SCHEDULED",
305
+ schedule_created: true,
306
+ cron_ready: true,
307
+ schedule_id: scheduleId,
308
+ run_at: runAtIso,
309
+ run_at_ms: due.runAtMs,
310
+ worker_pid: worker.pid,
311
+ worker_stdout_path: worker.stdoutPath,
312
+ worker_stderr_path: worker.stderrPath,
313
+ schedule,
314
+ prepare: prepared,
315
+ message: "Recommend run schedule created. The package-owned detached scheduler will start the prepared payload at run_at."
316
+ };
317
+ }
318
+
319
+ export function getRecommendScheduledRunTool({ args = {} } = {}) {
320
+ const scheduleId = safeIdPart(args.schedule_id || args.scheduleId);
321
+ if (!scheduleId) {
322
+ return {
323
+ status: "FAILED",
324
+ error: {
325
+ code: "INVALID_SCHEDULE_ID",
326
+ message: "schedule_id is required",
327
+ retryable: false
328
+ }
329
+ };
330
+ }
331
+ const schedule = readSchedule(scheduleId);
332
+ if (!schedule) {
333
+ return {
334
+ status: "FAILED",
335
+ error: {
336
+ code: "SCHEDULE_NOT_FOUND",
337
+ message: `schedule_id=${scheduleId} not found`,
338
+ retryable: false
339
+ }
340
+ };
341
+ }
342
+ let next = schedule;
343
+ if (!TERMINAL_SCHEDULE_STATES.has(normalizeText(schedule.state || schedule.status)) && schedule.pid && !isProcessAlive(schedule.pid)) {
344
+ next = writeSchedule(scheduleId, {
345
+ state: "failed",
346
+ status: "failed",
347
+ completed_at: nowIso(),
348
+ error: {
349
+ code: "SCHEDULE_WORKER_EXITED",
350
+ message: `Scheduled worker process exited before reaching a terminal state (pid=${schedule.pid}).`,
351
+ retryable: true
352
+ }
353
+ });
354
+ }
355
+ return {
356
+ status: "OK",
357
+ schedule_id: scheduleId,
358
+ schedule: next
359
+ };
360
+ }
361
+
362
+ export async function runScheduledRecommendWorker({ scheduleId }) {
363
+ const normalizedScheduleId = safeIdPart(scheduleId);
364
+ if (!normalizedScheduleId) return { ok: false, error: "schedule_id is required" };
365
+ let schedule = readSchedule(normalizedScheduleId);
366
+ if (!schedule) return { ok: false, error: `schedule_id=${normalizedScheduleId} not found` };
367
+ const runAtMs = Number(schedule.run_at_ms);
368
+ if (!Number.isFinite(runAtMs)) {
369
+ writeSchedule(normalizedScheduleId, {
370
+ state: "failed",
371
+ status: "failed",
372
+ completed_at: nowIso(),
373
+ error: {
374
+ code: "INVALID_SCHEDULE_STATE",
375
+ message: "schedule is missing a valid run_at_ms",
376
+ retryable: false
377
+ }
378
+ });
379
+ return { ok: false, error: "schedule is missing a valid run_at_ms" };
380
+ }
381
+
382
+ schedule = writeSchedule(normalizedScheduleId, {
383
+ state: "waiting",
384
+ status: "waiting",
385
+ pid: process.pid,
386
+ worker_started_at: nowIso()
387
+ });
388
+
389
+ while (Date.now() < runAtMs) {
390
+ await sleep(Math.min(30_000, Math.max(50, runAtMs - Date.now())));
391
+ const latest = readSchedule(normalizedScheduleId);
392
+ if (normalizeText(latest?.state) === "canceled") return { ok: true, canceled: true };
393
+ }
394
+
395
+ writeSchedule(normalizedScheduleId, {
396
+ state: "launching",
397
+ status: "launching",
398
+ launch_started_at: nowIso()
399
+ });
400
+
401
+ const started = await startRecommendPipelineRunTool({
402
+ workspaceRoot: schedule.workspace_root,
403
+ args: clonePlain(schedule.args, {})
404
+ });
405
+ if (started.status !== "ACCEPTED") {
406
+ writeSchedule(normalizedScheduleId, {
407
+ state: "failed",
408
+ status: "failed",
409
+ completed_at: nowIso(),
410
+ launch_payload: started,
411
+ error: started.error || {
412
+ code: "RECOMMEND_START_NOT_ACCEPTED",
413
+ message: started.status || "start_recommend_pipeline_run did not return ACCEPTED",
414
+ retryable: true
415
+ }
416
+ });
417
+ return { ok: false, error: started.error?.message || started.status || "not accepted" };
418
+ }
419
+
420
+ writeSchedule(normalizedScheduleId, {
421
+ state: "running",
422
+ status: "running",
423
+ run_id: started.run_id,
424
+ run: started.run || null,
425
+ launch_payload: started,
426
+ launched_at: nowIso()
427
+ });
428
+
429
+ while (true) {
430
+ const payload = getRecommendPipelineRunTool({ args: { run_id: started.run_id } });
431
+ const runState = normalizeText(payload?.run?.state || payload?.run?.status);
432
+ writeSchedule(normalizedScheduleId, {
433
+ state: runState && TERMINAL_RUN_STATES.has(runState) ? runState : "running",
434
+ status: runState && TERMINAL_RUN_STATES.has(runState) ? runState : "running",
435
+ run_id: started.run_id,
436
+ run: payload?.run || null,
437
+ last_poll_at: nowIso(),
438
+ completed_at: runState && TERMINAL_RUN_STATES.has(runState) ? nowIso() : undefined,
439
+ error: runState === "failed" ? (payload?.run?.error || payload?.error || null) : null
440
+ });
441
+ if (TERMINAL_RUN_STATES.has(runState)) break;
442
+ await sleep(1000);
443
+ }
444
+ return { ok: true, run_id: started.run_id };
445
+ }
446
+
447
+ export function __setRecommendSchedulerSpawnForTests(nextImpl) {
448
+ spawnProcessImpl = typeof nextImpl === "function" ? nextImpl : spawn;
449
+ }
450
+
451
+ function parseScheduleWorkerOptions(argv = process.argv.slice(2)) {
452
+ if (!Array.isArray(argv) || !argv.includes(SCHEDULE_WORKER_FLAG)) return null;
453
+ const idIndex = argv.indexOf(SCHEDULE_ID_FLAG);
454
+ return {
455
+ scheduleId: idIndex >= 0 ? normalizeText(argv[idIndex + 1]) : ""
456
+ };
457
+ }
458
+
459
+ const thisFilePath = fileURLToPath(import.meta.url);
460
+ if (process.argv[1] && path.resolve(process.argv[1]) === thisFilePath) {
461
+ const options = parseScheduleWorkerOptions(process.argv.slice(2));
462
+ if (options) {
463
+ runScheduledRecommendWorker(options).then((result) => {
464
+ if (!result?.ok) process.exitCode = 1;
465
+ }).catch((error) => {
466
+ try {
467
+ writeSchedule(options.scheduleId, {
468
+ state: "failed",
469
+ status: "failed",
470
+ completed_at: nowIso(),
471
+ error: {
472
+ code: "SCHEDULE_WORKER_UNHANDLED_ERROR",
473
+ message: error?.message || String(error || "schedule worker failed"),
474
+ retryable: true
475
+ }
476
+ });
477
+ } catch {}
478
+ console.error("[boss-recommend-mcp] scheduled recommend worker failed", error);
479
+ process.exitCode = 1;
480
+ });
481
+ }
482
+ }