auq-mcp-server 0.1.0
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/LICENSE +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- package/scripts/postinstall.cjs +51 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionManager - Core session management for AskUserQuestions MCP server
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
import { atomicDeleteFile, AtomicReadError, atomicReadFile, AtomicWriteError, atomicWriteFile, } from "./atomic-operations.js";
|
|
8
|
+
// import { PromiseFileWatcher } from "./file-watcher.js";
|
|
9
|
+
import { ResponseFormatter } from "./ResponseFormatter.js";
|
|
10
|
+
import { DEFAULT_SESSION_CONFIG, SESSION_FILES } from "./types.js";
|
|
11
|
+
import { createSafeFilename, ensureDirectoryExists, fileExists, getCurrentTimestamp, isTimestampExpired, resolveSessionDirectory, sanitizeSessionId, validateSessionDirectory, } from "./utils.js";
|
|
12
|
+
export class SessionManager {
|
|
13
|
+
baseDir;
|
|
14
|
+
config;
|
|
15
|
+
sessionsDir;
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
this.config = {
|
|
18
|
+
...DEFAULT_SESSION_CONFIG,
|
|
19
|
+
...config,
|
|
20
|
+
};
|
|
21
|
+
// Resolve the directory path using XDG-compliant resolution
|
|
22
|
+
this.baseDir = resolveSessionDirectory(this.config.baseDir);
|
|
23
|
+
this.sessionsDir = this.baseDir;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Clean up old sessions that have exceeded the retention period (garbage collection)
|
|
27
|
+
* This is different from session timeout - retention period determines when old
|
|
28
|
+
* sessions are permanently deleted, regardless of their completion status.
|
|
29
|
+
*/
|
|
30
|
+
async cleanupExpiredSessions() {
|
|
31
|
+
try {
|
|
32
|
+
const sessionIds = await this.getAllSessionIds();
|
|
33
|
+
let cleanedCount = 0;
|
|
34
|
+
for (const sessionId of sessionIds) {
|
|
35
|
+
const status = await this.getSessionStatus(sessionId);
|
|
36
|
+
if (status && this.isSessionRetentionExpired(status)) {
|
|
37
|
+
await this.deleteSession(sessionId);
|
|
38
|
+
cleanedCount++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return cleanedCount;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.warn("Failed to cleanup expired sessions:", error);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create a new session with unique ID
|
|
50
|
+
*/
|
|
51
|
+
async createSession(questions) {
|
|
52
|
+
if (!questions || questions.length === 0) {
|
|
53
|
+
throw new Error("At least one question is required to create a session");
|
|
54
|
+
}
|
|
55
|
+
const sessionId = uuidv4();
|
|
56
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
57
|
+
// Create session directory with secure permissions
|
|
58
|
+
await ensureDirectoryExists(sessionDir);
|
|
59
|
+
const timestamp = getCurrentTimestamp();
|
|
60
|
+
// Create session request
|
|
61
|
+
const sessionRequest = {
|
|
62
|
+
questions,
|
|
63
|
+
sessionId,
|
|
64
|
+
status: "pending",
|
|
65
|
+
timestamp,
|
|
66
|
+
};
|
|
67
|
+
// Create session status
|
|
68
|
+
const sessionStatus = {
|
|
69
|
+
createdAt: timestamp,
|
|
70
|
+
lastModified: timestamp,
|
|
71
|
+
sessionId,
|
|
72
|
+
status: "pending",
|
|
73
|
+
totalQuestions: questions.length,
|
|
74
|
+
};
|
|
75
|
+
// Write session files
|
|
76
|
+
await Promise.all([
|
|
77
|
+
this.writeSessionFile(sessionId, SESSION_FILES.REQUEST, sessionRequest),
|
|
78
|
+
this.writeSessionFile(sessionId, SESSION_FILES.STATUS, sessionStatus),
|
|
79
|
+
]);
|
|
80
|
+
return sessionId;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Delete a session directory and all files using atomic operations
|
|
84
|
+
*/
|
|
85
|
+
async deleteSession(sessionId) {
|
|
86
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
87
|
+
throw new Error(`Invalid session ID format: ${sessionId}`);
|
|
88
|
+
}
|
|
89
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
90
|
+
try {
|
|
91
|
+
// Delete session files atomically
|
|
92
|
+
const filesToDelete = [
|
|
93
|
+
SESSION_FILES.REQUEST,
|
|
94
|
+
SESSION_FILES.STATUS,
|
|
95
|
+
SESSION_FILES.ANSWERS,
|
|
96
|
+
];
|
|
97
|
+
for (const filename of filesToDelete) {
|
|
98
|
+
const filePath = join(sessionDir, createSafeFilename(sessionId, filename));
|
|
99
|
+
try {
|
|
100
|
+
await atomicDeleteFile(filePath);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
// Ignore file not found errors during cleanup
|
|
104
|
+
if (!error?.cause?.message.includes("ENOENT")) {
|
|
105
|
+
console.warn(`Warning: Failed to delete session file ${filename}: ${error}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Delete session directory
|
|
110
|
+
await fs.rm(sessionDir, { force: true, recursive: true });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw new Error(`Failed to delete session ${sessionId}: ${error}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Get all session IDs
|
|
118
|
+
*/
|
|
119
|
+
async getAllSessionIds() {
|
|
120
|
+
try {
|
|
121
|
+
return await fs.readdir(this.sessionsDir);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.code === "ENOENT") {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get configuration
|
|
132
|
+
*/
|
|
133
|
+
getConfig() {
|
|
134
|
+
return { ...this.config };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get session answers
|
|
138
|
+
*/
|
|
139
|
+
async getSessionAnswers(sessionId) {
|
|
140
|
+
return this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get session count
|
|
144
|
+
*/
|
|
145
|
+
async getSessionCount() {
|
|
146
|
+
const sessionIds = await this.getAllSessionIds();
|
|
147
|
+
return sessionIds.length;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get session request (questions)
|
|
151
|
+
*/
|
|
152
|
+
async getSessionRequest(sessionId) {
|
|
153
|
+
return this.readSessionFile(sessionId, SESSION_FILES.REQUEST);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get session status
|
|
157
|
+
*/
|
|
158
|
+
async getSessionStatus(sessionId) {
|
|
159
|
+
return this.readSessionFile(sessionId, SESSION_FILES.STATUS);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Initialize the session manager - create base directories
|
|
163
|
+
*/
|
|
164
|
+
async initialize() {
|
|
165
|
+
try {
|
|
166
|
+
await ensureDirectoryExists(this.sessionsDir);
|
|
167
|
+
// Validate the directory was created and is accessible
|
|
168
|
+
const isValid = await validateSessionDirectory(this.sessionsDir);
|
|
169
|
+
if (!isValid) {
|
|
170
|
+
throw new Error(`Failed to create or access session directory: ${this.sessionsDir}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new Error(`Failed to initialize session directories: ${error}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Check if maximum session limit is reached
|
|
179
|
+
*/
|
|
180
|
+
async isSessionLimitReached() {
|
|
181
|
+
const maxSessions = this.config.maxSessions || 100;
|
|
182
|
+
const currentCount = await this.getSessionCount();
|
|
183
|
+
return currentCount >= maxSessions;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Save session answers
|
|
187
|
+
*/
|
|
188
|
+
async saveSessionAnswers(sessionId, answers) {
|
|
189
|
+
const exists = await this.sessionExists(sessionId);
|
|
190
|
+
if (!exists) {
|
|
191
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
192
|
+
}
|
|
193
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.ANSWERS, answers);
|
|
194
|
+
// Update session status to completed
|
|
195
|
+
await this.updateSessionStatus(sessionId, "completed");
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Reject a session - mark as rejected by user
|
|
199
|
+
* This allows users to skip unwanted question sets
|
|
200
|
+
* @param sessionId - The session ID to reject
|
|
201
|
+
* @param reason - Optional reason for rejection (null if user skipped providing reason)
|
|
202
|
+
*/
|
|
203
|
+
async rejectSession(sessionId, reason) {
|
|
204
|
+
const exists = await this.sessionExists(sessionId);
|
|
205
|
+
if (!exists) {
|
|
206
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
207
|
+
}
|
|
208
|
+
// Update status to rejected with reason
|
|
209
|
+
const statusUpdate = {
|
|
210
|
+
rejectionReason: reason || null,
|
|
211
|
+
};
|
|
212
|
+
await this.updateSessionStatus(sessionId, "rejected", statusUpdate);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Check if session exists
|
|
216
|
+
*/
|
|
217
|
+
async sessionExists(sessionId) {
|
|
218
|
+
// First validate session ID format
|
|
219
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
223
|
+
return await fileExists(sessionDir);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Start a complete session lifecycle from creation to formatted response
|
|
227
|
+
*
|
|
228
|
+
* This is the main orchestration method that:
|
|
229
|
+
* 1. Creates a new session with the provided questions
|
|
230
|
+
* 2. Waits for user to submit answers (with optional timeout)
|
|
231
|
+
* 3. Reads and validates the answers
|
|
232
|
+
* 4. Formats the response according to PRD specification
|
|
233
|
+
* 5. Updates session status to completed
|
|
234
|
+
* 6. Returns the formatted response for the AI model
|
|
235
|
+
*
|
|
236
|
+
* @param questions - Array of questions to ask the user
|
|
237
|
+
* @returns Object containing sessionId and formatted response text
|
|
238
|
+
* @throws Error if timeout occurs, validation fails, or file operations fail
|
|
239
|
+
*/
|
|
240
|
+
async startSession(questions, callId) {
|
|
241
|
+
// Step 1: Create the session
|
|
242
|
+
const sessionId = await this.createSession(questions);
|
|
243
|
+
// Optionally attach callId metadata to request and status
|
|
244
|
+
if (callId) {
|
|
245
|
+
try {
|
|
246
|
+
const req = await this.getSessionRequest(sessionId);
|
|
247
|
+
if (req) {
|
|
248
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.REQUEST, {
|
|
249
|
+
...req,
|
|
250
|
+
callId,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const stat = await this.getSessionStatus(sessionId);
|
|
254
|
+
if (stat) {
|
|
255
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.STATUS, {
|
|
256
|
+
...stat,
|
|
257
|
+
callId,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
console.warn("Failed to write callId metadata:", e);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// Step 2: Calculate timeouts
|
|
267
|
+
const sessionTimeout = this.config.sessionTimeout ?? 0; // 0 = infinite
|
|
268
|
+
const watcherTimeout = sessionTimeout > 0
|
|
269
|
+
? Math.floor(sessionTimeout * 0.9) // 90% of session timeout
|
|
270
|
+
: 0; // Also infinite if session is infinite
|
|
271
|
+
// Step 3: Wait for answers with timeout
|
|
272
|
+
try {
|
|
273
|
+
await this.waitForAnswers(sessionId, watcherTimeout, callId);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
// Check if session was rejected by user
|
|
277
|
+
if (error instanceof Error && error.message === "SESSION_REJECTED") {
|
|
278
|
+
// Get session status to retrieve rejection reason
|
|
279
|
+
const status = await this.getSessionStatus(sessionId);
|
|
280
|
+
const reason = status?.rejectionReason;
|
|
281
|
+
// Format rejection message with reason for MCP caller
|
|
282
|
+
let formattedResponse = "User rejected this question set.\n\n";
|
|
283
|
+
if (reason) {
|
|
284
|
+
formattedResponse += `Rejection reason: "${reason}"\n\n`;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
formattedResponse += "No reason provided.\n\n";
|
|
288
|
+
}
|
|
289
|
+
// formattedResponse += "The user chose not to answer these questions at this time.";
|
|
290
|
+
return {
|
|
291
|
+
formattedResponse,
|
|
292
|
+
sessionId,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// Watcher timeout occurred
|
|
296
|
+
await this.updateSessionStatus(sessionId, "timed_out");
|
|
297
|
+
throw new Error(`Session ${sessionId} timed out waiting for user response: ${error instanceof Error ? error.message : String(error)}`);
|
|
298
|
+
}
|
|
299
|
+
// Step 4: Read and validate answers
|
|
300
|
+
let answers;
|
|
301
|
+
try {
|
|
302
|
+
answers = await this.getSessionAnswers(sessionId);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
// Handle JSON parse errors or other read failures
|
|
306
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (!answers) {
|
|
310
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
311
|
+
throw new Error(`Answers file was created but is invalid for session ${sessionId}`);
|
|
312
|
+
}
|
|
313
|
+
const request = await this.getSessionRequest(sessionId);
|
|
314
|
+
if (!request) {
|
|
315
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
316
|
+
throw new Error(`Session request not found: ${sessionId}`);
|
|
317
|
+
}
|
|
318
|
+
// Step 5: Validate answers match questions
|
|
319
|
+
try {
|
|
320
|
+
ResponseFormatter.validateAnswers(answers, request.questions);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
324
|
+
throw new Error(`Answer validation failed for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
325
|
+
}
|
|
326
|
+
// Step 6: Format the response according to PRD specification
|
|
327
|
+
const formattedResponse = ResponseFormatter.formatUserResponse(answers, request.questions);
|
|
328
|
+
// Step 7: Update final status
|
|
329
|
+
await this.updateSessionStatus(sessionId, "completed");
|
|
330
|
+
// Step 8: Return results
|
|
331
|
+
return {
|
|
332
|
+
formattedResponse,
|
|
333
|
+
sessionId,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// Ensure any errors are properly propagated with session context
|
|
338
|
+
if (error instanceof Error) {
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
throw new Error(`Session ${sessionId} failed: ${String(error)}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Update session status
|
|
346
|
+
*/
|
|
347
|
+
async updateSessionStatus(sessionId, status, additionalData) {
|
|
348
|
+
// First validate session ID format
|
|
349
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
350
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
351
|
+
}
|
|
352
|
+
const currentStatus = await this.getSessionStatus(sessionId);
|
|
353
|
+
if (!currentStatus) {
|
|
354
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
355
|
+
}
|
|
356
|
+
const updatedStatus = {
|
|
357
|
+
...currentStatus,
|
|
358
|
+
lastModified: getCurrentTimestamp(),
|
|
359
|
+
status,
|
|
360
|
+
...additionalData,
|
|
361
|
+
};
|
|
362
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.STATUS, updatedStatus);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Validate session data integrity
|
|
366
|
+
*/
|
|
367
|
+
async validateSession(sessionId) {
|
|
368
|
+
const issues = [];
|
|
369
|
+
// Check if session exists
|
|
370
|
+
if (!(await this.sessionExists(sessionId))) {
|
|
371
|
+
issues.push("Session directory does not exist");
|
|
372
|
+
return { issues, isValid: false };
|
|
373
|
+
}
|
|
374
|
+
// Check required files
|
|
375
|
+
const requiredFiles = [SESSION_FILES.REQUEST, SESSION_FILES.STATUS];
|
|
376
|
+
for (const filename of requiredFiles) {
|
|
377
|
+
const filePath = join(this.getSessionDir(sessionId), createSafeFilename(sessionId, filename));
|
|
378
|
+
if (!(await fileExists(filePath))) {
|
|
379
|
+
issues.push(`Required file missing: ${filename}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// If any required files are missing, don't try to read them
|
|
383
|
+
if (issues.length > 0) {
|
|
384
|
+
return {
|
|
385
|
+
issues,
|
|
386
|
+
isValid: false,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
// Validate session status consistency
|
|
390
|
+
const status = await this.getSessionStatus(sessionId);
|
|
391
|
+
const request = await this.getSessionRequest(sessionId);
|
|
392
|
+
if (status && request) {
|
|
393
|
+
if (status.sessionId !== sessionId) {
|
|
394
|
+
issues.push("Session ID mismatch in status file");
|
|
395
|
+
}
|
|
396
|
+
if (request.sessionId !== sessionId) {
|
|
397
|
+
issues.push("Session ID mismatch in request file");
|
|
398
|
+
}
|
|
399
|
+
if (status.totalQuestions !== request.questions.length) {
|
|
400
|
+
issues.push("Question count mismatch between status and request");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
issues.push("Could not read session status or request");
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
issues,
|
|
408
|
+
isValid: issues.length === 0,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Wait for user answers to be submitted for a specific session
|
|
413
|
+
* Returns the session ID when answers are detected, or rejects on timeout
|
|
414
|
+
*/
|
|
415
|
+
async waitForAnswers(sessionId, timeoutMs, expectedCallId) {
|
|
416
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
417
|
+
const answersPath = join(sessionDir, SESSION_FILES.ANSWERS);
|
|
418
|
+
const startTime = Date.now();
|
|
419
|
+
const pollInterval = 200; // ms
|
|
420
|
+
// Poll for answers.json existence, guard against rejection and timeout
|
|
421
|
+
// This avoids race conditions inherent in fs.watch when files are created before watch attaches
|
|
422
|
+
// and isolates each MCP call purely by sessionId
|
|
423
|
+
while (true) {
|
|
424
|
+
// Check for answers
|
|
425
|
+
if (await fileExists(answersPath)) {
|
|
426
|
+
if (expectedCallId) {
|
|
427
|
+
// Verify callId matches before resolving (defensive)
|
|
428
|
+
try {
|
|
429
|
+
const ans = await this.getSessionAnswers(sessionId);
|
|
430
|
+
if (ans && (!ans.callId || ans.callId === expectedCallId)) {
|
|
431
|
+
return sessionId;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// If read fails transiently, continue polling
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
return sessionId;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Check for rejection
|
|
443
|
+
const status = await this.getSessionStatus(sessionId);
|
|
444
|
+
if (status && status.status === "rejected") {
|
|
445
|
+
throw new Error("SESSION_REJECTED");
|
|
446
|
+
}
|
|
447
|
+
// Check for timeout
|
|
448
|
+
if (timeoutMs && timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
449
|
+
throw new Error("Timeout waiting for user response");
|
|
450
|
+
}
|
|
451
|
+
// Sleep before next poll
|
|
452
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Poll status.json to detect if session was rejected
|
|
457
|
+
* Throws error if status becomes "rejected"
|
|
458
|
+
*/
|
|
459
|
+
async pollForRejection(sessionId, timeoutMs) {
|
|
460
|
+
const startTime = Date.now();
|
|
461
|
+
const pollInterval = 500; // Poll every 500ms
|
|
462
|
+
while (true) {
|
|
463
|
+
// Check for timeout
|
|
464
|
+
if (timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
465
|
+
throw new Error("Timeout waiting for user response");
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const status = await this.getSessionStatus(sessionId);
|
|
469
|
+
if (status && status.status === "rejected") {
|
|
470
|
+
throw new Error("SESSION_REJECTED");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
// If we can't read status, ignore and continue polling
|
|
475
|
+
if (error instanceof Error && error.message === "SESSION_REJECTED") {
|
|
476
|
+
throw error; // Re-throw rejection
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Wait before next poll
|
|
480
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Get session directory path for a given session ID
|
|
485
|
+
*/
|
|
486
|
+
getSessionDir(sessionId) {
|
|
487
|
+
return join(this.sessionsDir, sessionId);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Check if a session is expired based on timeout
|
|
491
|
+
*/
|
|
492
|
+
isSessionExpired(status) {
|
|
493
|
+
const timeout = this.config.sessionTimeout || 0;
|
|
494
|
+
if (timeout <= 0)
|
|
495
|
+
return false;
|
|
496
|
+
return isTimestampExpired(status.lastModified, timeout);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Check if a session has exceeded the retention period and should be garbage collected
|
|
500
|
+
*/
|
|
501
|
+
isSessionRetentionExpired(status) {
|
|
502
|
+
const retentionPeriod = this.config.retentionPeriod ?? 604800000; // Default 7 days
|
|
503
|
+
if (retentionPeriod <= 0)
|
|
504
|
+
return false;
|
|
505
|
+
// Check the more recent timestamp (lastModified or createdAt)
|
|
506
|
+
// to determine if session is old enough for cleanup
|
|
507
|
+
const recentTimestamp = status.lastModified > status.createdAt
|
|
508
|
+
? status.lastModified
|
|
509
|
+
: status.createdAt;
|
|
510
|
+
return isTimestampExpired(recentTimestamp, retentionPeriod);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Validate session ID format
|
|
514
|
+
*/
|
|
515
|
+
isValidSessionId(sessionId) {
|
|
516
|
+
return sanitizeSessionId(sessionId);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Read data from a session file using atomic operations
|
|
520
|
+
*/
|
|
521
|
+
async readSessionFile(sessionId, filename, fallback = null) {
|
|
522
|
+
// First validate session ID format
|
|
523
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
524
|
+
return fallback;
|
|
525
|
+
}
|
|
526
|
+
const safeFilename = createSafeFilename(sessionId, filename);
|
|
527
|
+
const filePath = join(this.getSessionDir(sessionId), safeFilename);
|
|
528
|
+
try {
|
|
529
|
+
const content = await atomicReadFile(filePath, {
|
|
530
|
+
encoding: "utf8",
|
|
531
|
+
maxRetries: 3,
|
|
532
|
+
retryDelay: 100,
|
|
533
|
+
});
|
|
534
|
+
try {
|
|
535
|
+
return JSON.parse(content);
|
|
536
|
+
}
|
|
537
|
+
catch (parseError) {
|
|
538
|
+
throw new Error(`Failed to parse JSON from session file ${filename} for session ${sessionId}: ${parseError}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error instanceof AtomicReadError) {
|
|
543
|
+
// Check if the error is just that the file doesn't exist
|
|
544
|
+
if (error.cause?.message.includes("File does not exist") ||
|
|
545
|
+
error.message.includes("File does not exist")) {
|
|
546
|
+
return fallback;
|
|
547
|
+
}
|
|
548
|
+
throw new Error(`Failed to read session file ${filename} for session ${sessionId}: ${error.message}`);
|
|
549
|
+
}
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Write data to a session file using atomic operations
|
|
555
|
+
*/
|
|
556
|
+
async writeSessionFile(sessionId, filename, data) {
|
|
557
|
+
const safeFilename = createSafeFilename(sessionId, filename);
|
|
558
|
+
const filePath = join(this.getSessionDir(sessionId), safeFilename);
|
|
559
|
+
try {
|
|
560
|
+
await atomicWriteFile(filePath, JSON.stringify(data, null, 2), {
|
|
561
|
+
encoding: "utf8",
|
|
562
|
+
mode: 0o600,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error instanceof AtomicWriteError) {
|
|
567
|
+
throw new Error(`Failed to write session file ${filename} for session ${sessionId}: ${error.message}`);
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|