@reconcrap/boss-recommend-mcp 2.0.51 → 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/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
+ }