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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Formatter
|
|
3
|
+
*
|
|
4
|
+
* Formats user answers according to PRD specification for returning to AI models
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ResponseFormatter - Formats session answers into human-readable text
|
|
8
|
+
* according to the PRD specification
|
|
9
|
+
*/
|
|
10
|
+
export class ResponseFormatter {
|
|
11
|
+
/**
|
|
12
|
+
* Format user answers into PRD-compliant text response
|
|
13
|
+
*
|
|
14
|
+
* Format specification:
|
|
15
|
+
* - Header: "Here are the user's answers:"
|
|
16
|
+
* - Numbered questions: "1. {prompt}"
|
|
17
|
+
* - Arrow symbol for answers: "→ {label} — {description}"
|
|
18
|
+
* - Custom text: "→ Other: '{customText}'"
|
|
19
|
+
* - Double newline separation between questions
|
|
20
|
+
*
|
|
21
|
+
* @param answers - Session answer data containing user responses
|
|
22
|
+
* @param questions - Original questions asked to the user
|
|
23
|
+
* @returns Formatted text response ready to send to AI model
|
|
24
|
+
*/
|
|
25
|
+
static formatUserResponse(answers, questions) {
|
|
26
|
+
// Validate that we have matching questions and answers
|
|
27
|
+
if (answers.answers.length === 0) {
|
|
28
|
+
throw new Error("No answers provided in session");
|
|
29
|
+
}
|
|
30
|
+
if (questions.length === 0) {
|
|
31
|
+
throw new Error("No questions provided");
|
|
32
|
+
}
|
|
33
|
+
// Start with header
|
|
34
|
+
const lines = ["Here are the user's answers:", ""];
|
|
35
|
+
// Format each question and its answer
|
|
36
|
+
const formattedQuestions = [];
|
|
37
|
+
for (let i = 0; i < questions.length; i++) {
|
|
38
|
+
const question = questions[i];
|
|
39
|
+
const answer = answers.answers.find((a) => a.questionIndex === i);
|
|
40
|
+
if (!answer) {
|
|
41
|
+
// If no answer found for this question, skip it
|
|
42
|
+
// (This shouldn't happen in normal operation, but handle gracefully)
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Format the question and answer
|
|
46
|
+
const formattedQA = this.formatQuestion(question, answer, i + 1);
|
|
47
|
+
formattedQuestions.push(formattedQA);
|
|
48
|
+
}
|
|
49
|
+
// Join formatted questions with blank lines between them
|
|
50
|
+
lines.push(formattedQuestions.join("\n\n"));
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Validate that answers match the questions
|
|
55
|
+
*
|
|
56
|
+
* @param answers - Session answer data
|
|
57
|
+
* @param questions - Original questions
|
|
58
|
+
* @throws Error if validation fails
|
|
59
|
+
*/
|
|
60
|
+
static validateAnswers(answers, questions) {
|
|
61
|
+
// Check that we have answers
|
|
62
|
+
if (!answers || !answers.answers || answers.answers.length === 0) {
|
|
63
|
+
throw new Error("No answers provided");
|
|
64
|
+
}
|
|
65
|
+
// Check that we have questions
|
|
66
|
+
if (!questions || questions.length === 0) {
|
|
67
|
+
throw new Error("No questions provided");
|
|
68
|
+
}
|
|
69
|
+
// Check each answer references a valid question
|
|
70
|
+
for (const answer of answers.answers) {
|
|
71
|
+
if (answer.questionIndex < 0 ||
|
|
72
|
+
answer.questionIndex >= questions.length) {
|
|
73
|
+
throw new Error(`Answer references invalid question index: ${answer.questionIndex}`);
|
|
74
|
+
}
|
|
75
|
+
// Check that answer has either selectedOption or customText
|
|
76
|
+
if (!answer.selectedOption && !answer.customText) {
|
|
77
|
+
throw new Error(`Answer for question ${answer.questionIndex} has neither selectedOption nor customText`);
|
|
78
|
+
}
|
|
79
|
+
// If selectedOption is provided, verify it exists in the question's options
|
|
80
|
+
if (answer.selectedOption) {
|
|
81
|
+
const question = questions[answer.questionIndex];
|
|
82
|
+
const optionExists = question.options.some((opt) => opt.label === answer.selectedOption);
|
|
83
|
+
if (!optionExists) {
|
|
84
|
+
throw new Error(`Answer for question ${answer.questionIndex} references non-existent option: ${answer.selectedOption}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Format a single question and its answer
|
|
91
|
+
*
|
|
92
|
+
* @param question - The question data
|
|
93
|
+
* @param answer - The user's answer
|
|
94
|
+
* @param index - Question number (1-indexed for display)
|
|
95
|
+
* @returns Formatted string for this question/answer pair
|
|
96
|
+
*/
|
|
97
|
+
static formatQuestion(question, answer, index) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
// Add question with number
|
|
100
|
+
lines.push(`${index}. ${question.prompt}`);
|
|
101
|
+
// Format the answer
|
|
102
|
+
if (answer.customText) {
|
|
103
|
+
// Custom text answer - escape single quotes for display
|
|
104
|
+
const escapedText = answer.customText.replace(/'/g, "\\'");
|
|
105
|
+
lines.push(`→ Other: '${escapedText}'`);
|
|
106
|
+
}
|
|
107
|
+
else if (answer.selectedOption) {
|
|
108
|
+
// Selected option - find the option details
|
|
109
|
+
const option = question.options.find((opt) => opt.label === answer.selectedOption);
|
|
110
|
+
if (option) {
|
|
111
|
+
// Format with description if available
|
|
112
|
+
if (option.description) {
|
|
113
|
+
lines.push(`→ ${option.label} — ${option.description}`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push(`→ ${option.label}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Option not found - shouldn't happen, but handle gracefully
|
|
121
|
+
lines.push(`→ ${answer.selectedOption}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// No answer provided - this shouldn't happen
|
|
126
|
+
lines.push("→ No answer provided");
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionManager - Core session management for AskUserQuery 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
|
+
fileWatcher;
|
|
16
|
+
sessionsDir;
|
|
17
|
+
constructor(config = {}) {
|
|
18
|
+
this.config = {
|
|
19
|
+
...DEFAULT_SESSION_CONFIG,
|
|
20
|
+
...config,
|
|
21
|
+
};
|
|
22
|
+
// Resolve the directory path using XDG-compliant resolution
|
|
23
|
+
this.baseDir = resolveSessionDirectory(this.config.baseDir);
|
|
24
|
+
this.sessionsDir = this.baseDir;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Clean up old sessions that have exceeded the retention period (garbage collection)
|
|
28
|
+
* This is different from session timeout - retention period determines when old
|
|
29
|
+
* sessions are permanently deleted, regardless of their completion status.
|
|
30
|
+
*/
|
|
31
|
+
async cleanupExpiredSessions() {
|
|
32
|
+
try {
|
|
33
|
+
const sessionIds = await this.getAllSessionIds();
|
|
34
|
+
let cleanedCount = 0;
|
|
35
|
+
for (const sessionId of sessionIds) {
|
|
36
|
+
const status = await this.getSessionStatus(sessionId);
|
|
37
|
+
if (status && this.isSessionRetentionExpired(status)) {
|
|
38
|
+
await this.deleteSession(sessionId);
|
|
39
|
+
cleanedCount++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return cleanedCount;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.warn("Failed to cleanup expired sessions:", error);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create a new session with unique ID
|
|
51
|
+
*/
|
|
52
|
+
async createSession(questions) {
|
|
53
|
+
if (!questions || questions.length === 0) {
|
|
54
|
+
throw new Error("At least one question is required to create a session");
|
|
55
|
+
}
|
|
56
|
+
const sessionId = uuidv4();
|
|
57
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
58
|
+
// Create session directory with secure permissions
|
|
59
|
+
await ensureDirectoryExists(sessionDir);
|
|
60
|
+
const timestamp = getCurrentTimestamp();
|
|
61
|
+
// Create session request
|
|
62
|
+
const sessionRequest = {
|
|
63
|
+
questions,
|
|
64
|
+
sessionId,
|
|
65
|
+
status: "pending",
|
|
66
|
+
timestamp,
|
|
67
|
+
};
|
|
68
|
+
// Create session status
|
|
69
|
+
const sessionStatus = {
|
|
70
|
+
createdAt: timestamp,
|
|
71
|
+
lastModified: timestamp,
|
|
72
|
+
sessionId,
|
|
73
|
+
status: "pending",
|
|
74
|
+
totalQuestions: questions.length,
|
|
75
|
+
};
|
|
76
|
+
// Write session files
|
|
77
|
+
await Promise.all([
|
|
78
|
+
this.writeSessionFile(sessionId, SESSION_FILES.REQUEST, sessionRequest),
|
|
79
|
+
this.writeSessionFile(sessionId, SESSION_FILES.STATUS, sessionStatus),
|
|
80
|
+
]);
|
|
81
|
+
return sessionId;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Delete a session directory and all files using atomic operations
|
|
85
|
+
*/
|
|
86
|
+
async deleteSession(sessionId) {
|
|
87
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
88
|
+
throw new Error(`Invalid session ID format: ${sessionId}`);
|
|
89
|
+
}
|
|
90
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
91
|
+
try {
|
|
92
|
+
// Delete session files atomically
|
|
93
|
+
const filesToDelete = [
|
|
94
|
+
SESSION_FILES.REQUEST,
|
|
95
|
+
SESSION_FILES.STATUS,
|
|
96
|
+
SESSION_FILES.ANSWERS,
|
|
97
|
+
];
|
|
98
|
+
for (const filename of filesToDelete) {
|
|
99
|
+
const filePath = join(sessionDir, createSafeFilename(sessionId, filename));
|
|
100
|
+
try {
|
|
101
|
+
await atomicDeleteFile(filePath);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// Ignore file not found errors during cleanup
|
|
105
|
+
if (!error?.cause?.message.includes("ENOENT")) {
|
|
106
|
+
console.warn(`Warning: Failed to delete session file ${filename}: ${error}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Delete session directory
|
|
111
|
+
await fs.rm(sessionDir, { force: true, recursive: true });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new Error(`Failed to delete session ${sessionId}: ${error}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get all session IDs
|
|
119
|
+
*/
|
|
120
|
+
async getAllSessionIds() {
|
|
121
|
+
try {
|
|
122
|
+
return await fs.readdir(this.sessionsDir);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (error.code === "ENOENT") {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get configuration
|
|
133
|
+
*/
|
|
134
|
+
getConfig() {
|
|
135
|
+
return { ...this.config };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Get session answers
|
|
139
|
+
*/
|
|
140
|
+
async getSessionAnswers(sessionId) {
|
|
141
|
+
return this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get session count
|
|
145
|
+
*/
|
|
146
|
+
async getSessionCount() {
|
|
147
|
+
const sessionIds = await this.getAllSessionIds();
|
|
148
|
+
return sessionIds.length;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get session request (questions)
|
|
152
|
+
*/
|
|
153
|
+
async getSessionRequest(sessionId) {
|
|
154
|
+
return this.readSessionFile(sessionId, SESSION_FILES.REQUEST);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Get session status
|
|
158
|
+
*/
|
|
159
|
+
async getSessionStatus(sessionId) {
|
|
160
|
+
return this.readSessionFile(sessionId, SESSION_FILES.STATUS);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Initialize the session manager - create base directories
|
|
164
|
+
*/
|
|
165
|
+
async initialize() {
|
|
166
|
+
try {
|
|
167
|
+
await ensureDirectoryExists(this.sessionsDir);
|
|
168
|
+
// Validate the directory was created and is accessible
|
|
169
|
+
const isValid = await validateSessionDirectory(this.sessionsDir);
|
|
170
|
+
if (!isValid) {
|
|
171
|
+
throw new Error(`Failed to create or access session directory: ${this.sessionsDir}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new Error(`Failed to initialize session directories: ${error}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Check if maximum session limit is reached
|
|
180
|
+
*/
|
|
181
|
+
async isSessionLimitReached() {
|
|
182
|
+
const maxSessions = this.config.maxSessions || 100;
|
|
183
|
+
const currentCount = await this.getSessionCount();
|
|
184
|
+
return currentCount >= maxSessions;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Save session answers
|
|
188
|
+
*/
|
|
189
|
+
async saveSessionAnswers(sessionId, answers) {
|
|
190
|
+
const exists = await this.sessionExists(sessionId);
|
|
191
|
+
if (!exists) {
|
|
192
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
193
|
+
}
|
|
194
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.ANSWERS, answers);
|
|
195
|
+
// Update session status to completed
|
|
196
|
+
await this.updateSessionStatus(sessionId, "completed");
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Check if session exists
|
|
200
|
+
*/
|
|
201
|
+
async sessionExists(sessionId) {
|
|
202
|
+
// First validate session ID format
|
|
203
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
207
|
+
return await fileExists(sessionDir);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Start a complete session lifecycle from creation to formatted response
|
|
211
|
+
*
|
|
212
|
+
* This is the main orchestration method that:
|
|
213
|
+
* 1. Creates a new session with the provided questions
|
|
214
|
+
* 2. Waits for user to submit answers (with optional timeout)
|
|
215
|
+
* 3. Reads and validates the answers
|
|
216
|
+
* 4. Formats the response according to PRD specification
|
|
217
|
+
* 5. Updates session status to completed
|
|
218
|
+
* 6. Returns the formatted response for the AI model
|
|
219
|
+
*
|
|
220
|
+
* @param questions - Array of questions to ask the user
|
|
221
|
+
* @returns Object containing sessionId and formatted response text
|
|
222
|
+
* @throws Error if timeout occurs, validation fails, or file operations fail
|
|
223
|
+
*/
|
|
224
|
+
async startSession(questions) {
|
|
225
|
+
// Step 1: Create the session
|
|
226
|
+
const sessionId = await this.createSession(questions);
|
|
227
|
+
try {
|
|
228
|
+
// Step 2: Calculate timeouts
|
|
229
|
+
const sessionTimeout = this.config.sessionTimeout ?? 0; // 0 = infinite
|
|
230
|
+
const watcherTimeout = sessionTimeout > 0
|
|
231
|
+
? Math.floor(sessionTimeout * 0.9) // 90% of session timeout
|
|
232
|
+
: 0; // Also infinite if session is infinite
|
|
233
|
+
// Step 3: Wait for answers with timeout
|
|
234
|
+
try {
|
|
235
|
+
await this.waitForAnswers(sessionId, watcherTimeout);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// Watcher timeout occurred
|
|
239
|
+
await this.updateSessionStatus(sessionId, "timed_out");
|
|
240
|
+
throw new Error(`Session ${sessionId} timed out waiting for user response: ${error instanceof Error ? error.message : String(error)}`);
|
|
241
|
+
}
|
|
242
|
+
// Step 4: Read and validate answers
|
|
243
|
+
let answers;
|
|
244
|
+
try {
|
|
245
|
+
answers = await this.getSessionAnswers(sessionId);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// Handle JSON parse errors or other read failures
|
|
249
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
if (!answers) {
|
|
253
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
254
|
+
throw new Error(`Answers file was created but is invalid for session ${sessionId}`);
|
|
255
|
+
}
|
|
256
|
+
const request = await this.getSessionRequest(sessionId);
|
|
257
|
+
if (!request) {
|
|
258
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
259
|
+
throw new Error(`Session request not found: ${sessionId}`);
|
|
260
|
+
}
|
|
261
|
+
// Step 5: Validate answers match questions
|
|
262
|
+
try {
|
|
263
|
+
ResponseFormatter.validateAnswers(answers, request.questions);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
267
|
+
throw new Error(`Answer validation failed for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
268
|
+
}
|
|
269
|
+
// Step 6: Format the response according to PRD specification
|
|
270
|
+
const formattedResponse = ResponseFormatter.formatUserResponse(answers, request.questions);
|
|
271
|
+
// Step 7: Update final status
|
|
272
|
+
await this.updateSessionStatus(sessionId, "completed");
|
|
273
|
+
// Step 8: Return results
|
|
274
|
+
return {
|
|
275
|
+
formattedResponse,
|
|
276
|
+
sessionId,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
// Ensure any errors are properly propagated with session context
|
|
281
|
+
if (error instanceof Error) {
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`Session ${sessionId} failed: ${String(error)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Update session status
|
|
289
|
+
*/
|
|
290
|
+
async updateSessionStatus(sessionId, status, additionalData) {
|
|
291
|
+
// First validate session ID format
|
|
292
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
293
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
294
|
+
}
|
|
295
|
+
const currentStatus = await this.getSessionStatus(sessionId);
|
|
296
|
+
if (!currentStatus) {
|
|
297
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
298
|
+
}
|
|
299
|
+
const updatedStatus = {
|
|
300
|
+
...currentStatus,
|
|
301
|
+
lastModified: getCurrentTimestamp(),
|
|
302
|
+
status,
|
|
303
|
+
...additionalData,
|
|
304
|
+
};
|
|
305
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.STATUS, updatedStatus);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Validate session data integrity
|
|
309
|
+
*/
|
|
310
|
+
async validateSession(sessionId) {
|
|
311
|
+
const issues = [];
|
|
312
|
+
// Check if session exists
|
|
313
|
+
if (!(await this.sessionExists(sessionId))) {
|
|
314
|
+
issues.push("Session directory does not exist");
|
|
315
|
+
return { issues, isValid: false };
|
|
316
|
+
}
|
|
317
|
+
// Check required files
|
|
318
|
+
const requiredFiles = [SESSION_FILES.REQUEST, SESSION_FILES.STATUS];
|
|
319
|
+
for (const filename of requiredFiles) {
|
|
320
|
+
const filePath = join(this.getSessionDir(sessionId), createSafeFilename(sessionId, filename));
|
|
321
|
+
if (!(await fileExists(filePath))) {
|
|
322
|
+
issues.push(`Required file missing: ${filename}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// If any required files are missing, don't try to read them
|
|
326
|
+
if (issues.length > 0) {
|
|
327
|
+
return {
|
|
328
|
+
issues,
|
|
329
|
+
isValid: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// Validate session status consistency
|
|
333
|
+
const status = await this.getSessionStatus(sessionId);
|
|
334
|
+
const request = await this.getSessionRequest(sessionId);
|
|
335
|
+
if (status && request) {
|
|
336
|
+
if (status.sessionId !== sessionId) {
|
|
337
|
+
issues.push("Session ID mismatch in status file");
|
|
338
|
+
}
|
|
339
|
+
if (request.sessionId !== sessionId) {
|
|
340
|
+
issues.push("Session ID mismatch in request file");
|
|
341
|
+
}
|
|
342
|
+
if (status.totalQuestions !== request.questions.length) {
|
|
343
|
+
issues.push("Question count mismatch between status and request");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
issues.push("Could not read session status or request");
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
issues,
|
|
351
|
+
isValid: issues.length === 0,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Wait for user answers to be submitted for a specific session
|
|
356
|
+
* Returns the session ID when answers are detected, or rejects on timeout
|
|
357
|
+
*/
|
|
358
|
+
async waitForAnswers(sessionId, timeoutMs) {
|
|
359
|
+
if (!this.fileWatcher) {
|
|
360
|
+
this.fileWatcher = new PromiseFileWatcher({
|
|
361
|
+
debounceMs: 100,
|
|
362
|
+
ignoreInitial: true,
|
|
363
|
+
timeoutMs: timeoutMs ?? 0, // 0 means infinite wait time by default
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const sessionDir = this.getSessionDir(sessionId);
|
|
367
|
+
try {
|
|
368
|
+
const answersPath = await this.fileWatcher.waitForFile(sessionDir, SESSION_FILES.ANSWERS);
|
|
369
|
+
// Clean up the watcher after successful wait
|
|
370
|
+
this.fileWatcher.cleanup();
|
|
371
|
+
this.fileWatcher = undefined;
|
|
372
|
+
// Verify the answers file exists and return the session ID
|
|
373
|
+
console.debug(`Answers file created: ${answersPath}`);
|
|
374
|
+
return sessionId;
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
// Clean up on error
|
|
378
|
+
if (this.fileWatcher) {
|
|
379
|
+
this.fileWatcher.cleanup();
|
|
380
|
+
this.fileWatcher = undefined;
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Get session directory path for a given session ID
|
|
387
|
+
*/
|
|
388
|
+
getSessionDir(sessionId) {
|
|
389
|
+
return join(this.sessionsDir, sessionId);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Check if a session is expired based on timeout
|
|
393
|
+
*/
|
|
394
|
+
isSessionExpired(status) {
|
|
395
|
+
const timeout = this.config.sessionTimeout || 0;
|
|
396
|
+
if (timeout <= 0)
|
|
397
|
+
return false;
|
|
398
|
+
return isTimestampExpired(status.lastModified, timeout);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Check if a session has exceeded the retention period and should be garbage collected
|
|
402
|
+
*/
|
|
403
|
+
isSessionRetentionExpired(status) {
|
|
404
|
+
const retentionPeriod = this.config.retentionPeriod ?? 604800000; // Default 7 days
|
|
405
|
+
if (retentionPeriod <= 0)
|
|
406
|
+
return false;
|
|
407
|
+
// Check the more recent timestamp (lastModified or createdAt)
|
|
408
|
+
// to determine if session is old enough for cleanup
|
|
409
|
+
const recentTimestamp = status.lastModified > status.createdAt
|
|
410
|
+
? status.lastModified
|
|
411
|
+
: status.createdAt;
|
|
412
|
+
return isTimestampExpired(recentTimestamp, retentionPeriod);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Validate session ID format
|
|
416
|
+
*/
|
|
417
|
+
isValidSessionId(sessionId) {
|
|
418
|
+
return sanitizeSessionId(sessionId);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Read data from a session file using atomic operations
|
|
422
|
+
*/
|
|
423
|
+
async readSessionFile(sessionId, filename, fallback = null) {
|
|
424
|
+
// First validate session ID format
|
|
425
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
426
|
+
return fallback;
|
|
427
|
+
}
|
|
428
|
+
const safeFilename = createSafeFilename(sessionId, filename);
|
|
429
|
+
const filePath = join(this.getSessionDir(sessionId), safeFilename);
|
|
430
|
+
try {
|
|
431
|
+
const content = await atomicReadFile(filePath, {
|
|
432
|
+
encoding: "utf8",
|
|
433
|
+
maxRetries: 3,
|
|
434
|
+
retryDelay: 100,
|
|
435
|
+
});
|
|
436
|
+
try {
|
|
437
|
+
return JSON.parse(content);
|
|
438
|
+
}
|
|
439
|
+
catch (parseError) {
|
|
440
|
+
throw new Error(`Failed to parse JSON from session file ${filename} for session ${sessionId}: ${parseError}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
if (error instanceof AtomicReadError) {
|
|
445
|
+
// Check if the error is just that the file doesn't exist
|
|
446
|
+
if (error.cause?.message.includes("File does not exist") ||
|
|
447
|
+
error.message.includes("File does not exist")) {
|
|
448
|
+
return fallback;
|
|
449
|
+
}
|
|
450
|
+
throw new Error(`Failed to read session file ${filename} for session ${sessionId}: ${error.message}`);
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Write data to a session file using atomic operations
|
|
457
|
+
*/
|
|
458
|
+
async writeSessionFile(sessionId, filename, data) {
|
|
459
|
+
const safeFilename = createSafeFilename(sessionId, filename);
|
|
460
|
+
const filePath = join(this.getSessionDir(sessionId), safeFilename);
|
|
461
|
+
try {
|
|
462
|
+
await atomicWriteFile(filePath, JSON.stringify(data, null, 2), {
|
|
463
|
+
encoding: "utf8",
|
|
464
|
+
mode: 0o600,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (error instanceof AtomicWriteError) {
|
|
469
|
+
throw new Error(`Failed to write session file ${filename} for session ${sessionId}: ${error.message}`);
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|