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,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for complete session workflow
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { SessionManager } from "../SessionManager.js";
|
|
8
|
+
import { SESSION_FILES } from "../types.js";
|
|
9
|
+
describe("Complete Session Workflow Integration", () => {
|
|
10
|
+
const testDir = "/tmp/auq-workflow-test";
|
|
11
|
+
const sessionManager = new SessionManager({ baseDir: testDir });
|
|
12
|
+
const sampleQuestions = [
|
|
13
|
+
{
|
|
14
|
+
prompt: "What is your preferred development environment?",
|
|
15
|
+
options: [
|
|
16
|
+
{ label: "VS Code", description: "Microsoft's popular editor" },
|
|
17
|
+
{ label: "Vim", description: "Classic text editor" },
|
|
18
|
+
{ label: "JetBrains IDE", description: "Professional IDE suite" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
prompt: "How do you handle version control?",
|
|
23
|
+
options: [
|
|
24
|
+
{ label: "Git", description: "Distributed version control" },
|
|
25
|
+
{ label: "SVN", description: "Centralized version control" },
|
|
26
|
+
{ label: "Other", description: "Different system" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
// Clean up test directory before each test
|
|
32
|
+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
|
|
33
|
+
await sessionManager.initialize();
|
|
34
|
+
});
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
// Clean up test directory after each test
|
|
37
|
+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
|
|
38
|
+
});
|
|
39
|
+
describe("completeSessionWorkflow", () => {
|
|
40
|
+
it("should execute complete workflow from creation to formatted response", async () => {
|
|
41
|
+
// Start the workflow in background
|
|
42
|
+
const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
43
|
+
// Give it a moment to start and create the session
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
45
|
+
// Get the session ID that was created
|
|
46
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
47
|
+
expect(sessionIds.length).toBe(1);
|
|
48
|
+
const sessionId = sessionIds[0];
|
|
49
|
+
// Simulate user answers
|
|
50
|
+
const userAnswers = {
|
|
51
|
+
sessionId,
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
answers: [
|
|
54
|
+
{
|
|
55
|
+
questionIndex: 0,
|
|
56
|
+
selectedOption: "VS Code",
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
questionIndex: 1,
|
|
61
|
+
selectedOption: "Git",
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
// Save answers to trigger workflow completion
|
|
67
|
+
const sessionDir = sessionManager.getSessionDir(sessionId);
|
|
68
|
+
await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
|
|
69
|
+
// Wait for workflow to complete
|
|
70
|
+
const result = await workflowPromise;
|
|
71
|
+
// Verify the result structure
|
|
72
|
+
expect(result).toHaveProperty("formatted_response");
|
|
73
|
+
expect(result).toHaveProperty("sessionId");
|
|
74
|
+
expect(typeof result.formatted_response).toBe("string");
|
|
75
|
+
expect(typeof result.sessionId).toBe("string");
|
|
76
|
+
// Verify the formatted response content
|
|
77
|
+
expect(result.formatted_response).toContain("Here are the user's answers:");
|
|
78
|
+
expect(result.formatted_response).toContain("1. What is your preferred development environment?");
|
|
79
|
+
expect(result.formatted_response).toContain("2. How do you handle version control?");
|
|
80
|
+
// Verify session was created properly
|
|
81
|
+
const sessionExists = await sessionManager.sessionExists(result.sessionId);
|
|
82
|
+
expect(sessionExists).toBe(true);
|
|
83
|
+
// Verify session status is completed
|
|
84
|
+
const sessionStatus = await sessionManager.getSessionStatus(result.sessionId);
|
|
85
|
+
expect(sessionStatus?.status).toBe("completed");
|
|
86
|
+
}, 15000); // Longer timeout for workflow test
|
|
87
|
+
it("should handle workflow with custom answers", async () => {
|
|
88
|
+
// Start the workflow in background
|
|
89
|
+
const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
90
|
+
// Give it a moment to start and create the session
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
92
|
+
// Get the session ID that was created
|
|
93
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
94
|
+
expect(sessionIds.length).toBe(1);
|
|
95
|
+
const sessionId = sessionIds[0];
|
|
96
|
+
// Simulate user answers with custom text
|
|
97
|
+
const userAnswers = {
|
|
98
|
+
sessionId,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
answers: [
|
|
101
|
+
{
|
|
102
|
+
questionIndex: 0,
|
|
103
|
+
selectedOption: "VS Code",
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
questionIndex: 1,
|
|
108
|
+
customText: "I use Git with custom aliases and hooks",
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
// Save answers to trigger workflow completion
|
|
114
|
+
const sessionDir = sessionManager.getSessionDir(sessionId);
|
|
115
|
+
await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
|
|
116
|
+
// Wait for workflow to complete
|
|
117
|
+
const result = await workflowPromise;
|
|
118
|
+
// Verify the formatted response includes custom answer
|
|
119
|
+
expect(result.formatted_response).toContain("→ VS Code — Microsoft's popular editor");
|
|
120
|
+
expect(result.formatted_response).toContain("→ Other: 'I use Git with custom aliases and hooks'");
|
|
121
|
+
}, 15000);
|
|
122
|
+
it("should handle workflow with timeout", async () => {
|
|
123
|
+
const timeoutMs = 1000; // 1 second timeout
|
|
124
|
+
try {
|
|
125
|
+
// Start workflow but don't provide answers - should timeout
|
|
126
|
+
const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions, timeoutMs);
|
|
127
|
+
await workflowPromise;
|
|
128
|
+
expect.fail("Should have timed out");
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
expect(error).toBeInstanceOf(Error);
|
|
132
|
+
expect(error.message).toContain("Timeout");
|
|
133
|
+
}
|
|
134
|
+
}, 3000);
|
|
135
|
+
it("should handle empty questions array", async () => {
|
|
136
|
+
try {
|
|
137
|
+
await sessionManager.completeSessionWorkflow([]);
|
|
138
|
+
expect.fail("Should have thrown error for empty questions");
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
expect(error).toBeInstanceOf(Error);
|
|
142
|
+
expect(error.message).toContain("At least one question is required");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
it("should update session status throughout workflow", async () => {
|
|
146
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
147
|
+
// Initial status should be pending
|
|
148
|
+
let status = await sessionManager.getSessionStatus(sessionId);
|
|
149
|
+
expect(status?.status).toBe("pending");
|
|
150
|
+
// After workflow completion, status should be completed
|
|
151
|
+
// Note: This test might be timing-sensitive since we can't easily test intermediate states
|
|
152
|
+
// without more complex mocking
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("Session State Transitions", () => {
|
|
156
|
+
it("should properly transition through all states", async () => {
|
|
157
|
+
// Start workflow in background
|
|
158
|
+
const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
159
|
+
// Give it a moment to start and create the session
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
161
|
+
// Get session ID by checking existing sessions
|
|
162
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
163
|
+
expect(sessionIds.length).toBe(1);
|
|
164
|
+
const sessionId = sessionIds[0];
|
|
165
|
+
// Should be in-progress or processing state
|
|
166
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
167
|
+
expect(["in-progress", "processing"]).toContain(status?.status || "");
|
|
168
|
+
// Now complete the workflow by providing answers
|
|
169
|
+
const sessionDir = sessionManager.getSessionDir(sessionId);
|
|
170
|
+
const userAnswers = {
|
|
171
|
+
sessionId,
|
|
172
|
+
timestamp: new Date().toISOString(),
|
|
173
|
+
answers: [
|
|
174
|
+
{
|
|
175
|
+
questionIndex: 0,
|
|
176
|
+
selectedOption: "JetBrains IDE",
|
|
177
|
+
timestamp: new Date().toISOString(),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
questionIndex: 1,
|
|
181
|
+
selectedOption: "Git",
|
|
182
|
+
timestamp: new Date().toISOString(),
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
|
|
187
|
+
// Wait for workflow to complete
|
|
188
|
+
const result = await workflowPromise;
|
|
189
|
+
// Final status should be completed
|
|
190
|
+
const finalStatus = await sessionManager.getSessionStatus(sessionId);
|
|
191
|
+
expect(finalStatus?.status).toBe("completed");
|
|
192
|
+
expect(result.sessionId).toBe(sessionId);
|
|
193
|
+
}, 10000);
|
|
194
|
+
it("should handle workflow failure and mark session as abandoned", async () => {
|
|
195
|
+
// Start workflow in background
|
|
196
|
+
const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
197
|
+
// Give it a moment to start and create the session
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
199
|
+
// Get the session ID that was created
|
|
200
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
201
|
+
expect(sessionIds.length).toBe(1);
|
|
202
|
+
const sessionId = sessionIds[0];
|
|
203
|
+
// Simulate corrupted answers file
|
|
204
|
+
const sessionDir = sessionManager.getSessionDir(sessionId);
|
|
205
|
+
await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), "invalid json content");
|
|
206
|
+
// Give file system time to register
|
|
207
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
208
|
+
try {
|
|
209
|
+
// This should fail due to invalid JSON
|
|
210
|
+
await workflowPromise;
|
|
211
|
+
expect.fail("Should have failed due to invalid answers");
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
// Session should be marked as abandoned
|
|
215
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
216
|
+
expect(status?.status).toBe("abandoned");
|
|
217
|
+
expect(status?.error).toBeTruthy();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe("Session Metadata and Debugging", () => {
|
|
222
|
+
it("should provide enhanced session metadata", async () => {
|
|
223
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
224
|
+
// Get enhanced metadata
|
|
225
|
+
const metadata = await sessionManager.getSessionStatusWithMetadata(sessionId);
|
|
226
|
+
expect(metadata).toHaveProperty("status");
|
|
227
|
+
expect(metadata).toHaveProperty("request");
|
|
228
|
+
expect(metadata.status?.sessionId).toBe(sessionId);
|
|
229
|
+
expect(metadata.request?.questions).toEqual(sampleQuestions);
|
|
230
|
+
// Answer should be undefined initially (no answers file exists yet)
|
|
231
|
+
expect(metadata.answer).toBeUndefined();
|
|
232
|
+
expect(metadata.formattedResponse).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
it("should include formatted response when answers exist", async () => {
|
|
235
|
+
// Create session and provide answers
|
|
236
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
237
|
+
const sessionDir = sessionManager.getSessionDir(sessionId);
|
|
238
|
+
const userAnswers = {
|
|
239
|
+
sessionId,
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
answers: [
|
|
242
|
+
{
|
|
243
|
+
questionIndex: 0,
|
|
244
|
+
selectedOption: "Vim",
|
|
245
|
+
timestamp: new Date().toISOString(),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
questionIndex: 1,
|
|
249
|
+
selectedOption: "Git",
|
|
250
|
+
timestamp: new Date().toISOString(),
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
|
|
255
|
+
// Get enhanced metadata
|
|
256
|
+
const metadata = await sessionManager.getSessionStatusWithMetadata(sessionId);
|
|
257
|
+
expect(metadata.answer).toBeTruthy();
|
|
258
|
+
expect(metadata.formattedResponse).toBeTruthy();
|
|
259
|
+
expect(metadata.formattedResponse).toContain("→ Vim — Classic text editor");
|
|
260
|
+
expect(metadata.formattedResponse).toContain("→ Git — Distributed version control");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe("Error Recovery and Edge Cases", () => {
|
|
264
|
+
it("should handle multiple session workflows", async () => {
|
|
265
|
+
// Note: Testing truly concurrent workflows reveals a shared resource issue with fileWatcher
|
|
266
|
+
// For now, test sequential workflows which should work fine
|
|
267
|
+
// First workflow
|
|
268
|
+
const workflow1Promise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
269
|
+
// Wait for first session to be created
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
271
|
+
let sessionIds = await sessionManager.getAllSessionIds();
|
|
272
|
+
expect(sessionIds.length).toBe(1);
|
|
273
|
+
const sessionId1 = sessionIds[0];
|
|
274
|
+
// Complete first workflow
|
|
275
|
+
const sessionDir1 = sessionManager.getSessionDir(sessionId1);
|
|
276
|
+
const userAnswers1 = {
|
|
277
|
+
sessionId: sessionId1,
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
answers: [
|
|
280
|
+
{
|
|
281
|
+
questionIndex: 0,
|
|
282
|
+
selectedOption: "VS Code",
|
|
283
|
+
timestamp: new Date().toISOString(),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
questionIndex: 1,
|
|
287
|
+
selectedOption: "Git",
|
|
288
|
+
timestamp: new Date().toISOString(),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
await fs.writeFile(join(sessionDir1, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers1, null, 2));
|
|
293
|
+
const result1 = await workflow1Promise;
|
|
294
|
+
// Second workflow (after first completes)
|
|
295
|
+
const workflow2Promise = sessionManager.completeSessionWorkflow(sampleQuestions);
|
|
296
|
+
// Wait for second session to be created
|
|
297
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
298
|
+
sessionIds = await sessionManager.getAllSessionIds();
|
|
299
|
+
expect(sessionIds.length).toBe(2);
|
|
300
|
+
const sessionId2 = sessionIds.find(id => id !== sessionId1);
|
|
301
|
+
// Complete second workflow
|
|
302
|
+
const sessionDir2 = sessionManager.getSessionDir(sessionId2);
|
|
303
|
+
const userAnswers2 = {
|
|
304
|
+
sessionId: sessionId2,
|
|
305
|
+
timestamp: new Date().toISOString(),
|
|
306
|
+
answers: [
|
|
307
|
+
{
|
|
308
|
+
questionIndex: 0,
|
|
309
|
+
selectedOption: "JetBrains IDE",
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
questionIndex: 1,
|
|
314
|
+
selectedOption: "SVN",
|
|
315
|
+
timestamp: new Date().toISOString(),
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
await fs.writeFile(join(sessionDir2, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers2, null, 2));
|
|
320
|
+
const result2 = await workflow2Promise;
|
|
321
|
+
// Verify both workflows completed successfully with different results
|
|
322
|
+
expect(result1.sessionId).not.toBe(result2.sessionId);
|
|
323
|
+
expect(result1.formatted_response).toContain("→ VS Code");
|
|
324
|
+
expect(result1.formatted_response).toContain("→ Git");
|
|
325
|
+
expect(result2.formatted_response).toContain("→ JetBrains IDE");
|
|
326
|
+
expect(result2.formatted_response).toContain("→ SVN");
|
|
327
|
+
// Both sessions should be marked as completed
|
|
328
|
+
const status1 = await sessionManager.getSessionStatus(result1.sessionId);
|
|
329
|
+
const status2 = await sessionManager.getSessionStatus(result2.sessionId);
|
|
330
|
+
expect(status1?.status).toBe("completed");
|
|
331
|
+
expect(status2?.status).toBe("completed");
|
|
332
|
+
}, 15000);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file operations for session management
|
|
3
|
+
* Provides truly atomic read/write operations to prevent data corruption
|
|
4
|
+
*/
|
|
5
|
+
import { constants, copyFile, rename } from "fs";
|
|
6
|
+
import { promises as fs } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { fileExists } from "./utils.js";
|
|
10
|
+
/**
|
|
11
|
+
* Error types for atomic operations
|
|
12
|
+
*/
|
|
13
|
+
export class AtomicOperationError extends Error {
|
|
14
|
+
operation;
|
|
15
|
+
filePath;
|
|
16
|
+
cause;
|
|
17
|
+
constructor(message, operation, filePath, cause) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.operation = operation;
|
|
20
|
+
this.filePath = filePath;
|
|
21
|
+
this.cause = cause;
|
|
22
|
+
this.name = "AtomicOperationError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class AtomicReadError extends AtomicOperationError {
|
|
26
|
+
constructor(filePath, cause) {
|
|
27
|
+
super(`Atomic read failed: ${filePath}`, "read", filePath, cause);
|
|
28
|
+
this.name = "AtomicReadError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class AtomicWriteError extends AtomicOperationError {
|
|
32
|
+
constructor(filePath, cause) {
|
|
33
|
+
super(`Atomic write failed: ${filePath}`, "write", filePath, cause);
|
|
34
|
+
this.name = "AtomicWriteError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class FileLockError extends AtomicOperationError {
|
|
38
|
+
constructor(filePath, cause) {
|
|
39
|
+
super(`Failed to acquire file lock: ${filePath}`, "write", filePath, cause);
|
|
40
|
+
this.name = "FileLockError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const DEFAULT_WRITE_OPTIONS = {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
flag: "w",
|
|
46
|
+
maxRetries: 3,
|
|
47
|
+
mode: 0o600,
|
|
48
|
+
retryDelay: 100,
|
|
49
|
+
tmpDir: tmpdir(),
|
|
50
|
+
};
|
|
51
|
+
const DEFAULT_READ_OPTIONS = {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
flag: "r",
|
|
54
|
+
maxRetries: 3,
|
|
55
|
+
retryDelay: 100,
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Atomic file copy operation
|
|
59
|
+
*/
|
|
60
|
+
export async function atomicCopyFile(sourcePath, destPath, options = {}) {
|
|
61
|
+
const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
|
|
62
|
+
try {
|
|
63
|
+
// Acquire destination lock
|
|
64
|
+
await acquireLock(destPath);
|
|
65
|
+
// Perform atomic copy
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
copyFile(sourcePath, destPath, constants.COPYFILE_EXCL, (error) => {
|
|
68
|
+
if (error) {
|
|
69
|
+
reject(error);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
resolve();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
// Set correct permissions on destination
|
|
77
|
+
await fs.chmod(destPath, opts.mode);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new AtomicOperationError(`Atomic copy failed: ${sourcePath} -> ${destPath}`, "write", destPath, error);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await releaseLock(destPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Atomic file delete operation
|
|
88
|
+
*/
|
|
89
|
+
export async function atomicDeleteFile(filePath) {
|
|
90
|
+
try {
|
|
91
|
+
await acquireLock(filePath);
|
|
92
|
+
await fs.unlink(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT") {
|
|
96
|
+
// File doesn't exist, that's okay for delete
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw new AtomicOperationError(`Atomic delete failed: ${filePath}`, "write", filePath, error);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await releaseLock(filePath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Atomic read operation with retry logic
|
|
107
|
+
*/
|
|
108
|
+
export async function atomicReadFile(filePath, options = {}) {
|
|
109
|
+
const opts = { ...DEFAULT_READ_OPTIONS, ...options };
|
|
110
|
+
let lastError = null;
|
|
111
|
+
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
// Check if file exists
|
|
114
|
+
if (!(await fileExists(filePath))) {
|
|
115
|
+
throw new AtomicReadError(filePath, new Error("File does not exist"));
|
|
116
|
+
}
|
|
117
|
+
// Acquire read lock (shared lock)
|
|
118
|
+
await acquireLock(filePath);
|
|
119
|
+
try {
|
|
120
|
+
const data = await fs.readFile(filePath, {
|
|
121
|
+
encoding: opts.encoding,
|
|
122
|
+
flag: opts.flag,
|
|
123
|
+
});
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await releaseLock(filePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
// Don't retry on certain errors
|
|
133
|
+
if (error instanceof AtomicReadError ||
|
|
134
|
+
error.code === "ENOENT" ||
|
|
135
|
+
error.code === "EACCES") {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
// Wait before retrying
|
|
139
|
+
if (attempt < opts.maxRetries - 1) {
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, opts.retryDelay * Math.pow(2, attempt)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new AtomicReadError(filePath, lastError || new Error("Unknown error"));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Atomic write operation using temporary file and rename
|
|
148
|
+
*/
|
|
149
|
+
export async function atomicWriteFile(filePath, data, options = {}) {
|
|
150
|
+
const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
|
|
151
|
+
const tempPath = generateTempPath(filePath, opts.tmpDir);
|
|
152
|
+
try {
|
|
153
|
+
// Acquire file lock
|
|
154
|
+
await acquireLock(filePath);
|
|
155
|
+
// Write to temporary file first
|
|
156
|
+
await fs.writeFile(tempPath, data, {
|
|
157
|
+
encoding: opts.encoding,
|
|
158
|
+
flag: opts.flag,
|
|
159
|
+
mode: opts.mode,
|
|
160
|
+
});
|
|
161
|
+
// Verify the temporary file was written correctly
|
|
162
|
+
const verificationData = await fs.readFile(tempPath, opts.encoding);
|
|
163
|
+
if (verificationData !== data) {
|
|
164
|
+
throw new AtomicWriteError(filePath, new Error("Data verification failed after write"));
|
|
165
|
+
}
|
|
166
|
+
// Atomic rename operation
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
rename(tempPath, filePath, (error) => {
|
|
169
|
+
if (error) {
|
|
170
|
+
reject(error);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
resolve();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// Verify the final file exists and has correct permissions
|
|
178
|
+
try {
|
|
179
|
+
await fs.access(filePath, constants.F_OK);
|
|
180
|
+
const stats = await fs.stat(filePath);
|
|
181
|
+
// Check if mode is correct (at least read/write for owner)
|
|
182
|
+
if ((stats.mode & 0o600) !== 0o600) {
|
|
183
|
+
await fs.chmod(filePath, opts.mode);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new AtomicWriteError(filePath, new Error(`File verification failed after rename: ${error}`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
// Clean up temporary file on failure
|
|
192
|
+
try {
|
|
193
|
+
await fs.unlink(tempPath);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Ignore cleanup errors
|
|
197
|
+
}
|
|
198
|
+
throw new AtomicWriteError(filePath, error);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
// Always release the lock
|
|
202
|
+
await releaseLock(filePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Check if a file is locked
|
|
207
|
+
*/
|
|
208
|
+
export async function isFileLocked(filePath) {
|
|
209
|
+
const lockPath = `${filePath}.lock`;
|
|
210
|
+
return await fileExists(lockPath);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Wait for a file to become unlocked (with timeout)
|
|
214
|
+
*/
|
|
215
|
+
export async function waitForFileUnlock(filePath, timeout = 10000) {
|
|
216
|
+
const startTime = Date.now();
|
|
217
|
+
while (Date.now() - startTime < timeout) {
|
|
218
|
+
if (!(await isFileLocked(filePath))) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
222
|
+
}
|
|
223
|
+
throw new FileLockError(filePath, new Error("Timeout waiting for file unlock"));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Simple file lock implementation using lock files
|
|
227
|
+
*/
|
|
228
|
+
async function acquireLock(filePath, timeout = 5000) {
|
|
229
|
+
const lockPath = `${filePath}.lock`;
|
|
230
|
+
const startTime = Date.now();
|
|
231
|
+
while (Date.now() - startTime < timeout) {
|
|
232
|
+
try {
|
|
233
|
+
// Try to create lock file (O_EXCL ensures atomic creation)
|
|
234
|
+
await fs.writeFile(lockPath, process.pid.toString(), {
|
|
235
|
+
encoding: "utf8",
|
|
236
|
+
flag: "wx", // Write and fail if file exists
|
|
237
|
+
mode: 0o600,
|
|
238
|
+
});
|
|
239
|
+
return; // Lock acquired
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code === "EEXIST") {
|
|
243
|
+
// Lock file exists, check if it's stale
|
|
244
|
+
try {
|
|
245
|
+
const lockContent = await fs.readFile(lockPath, "utf8");
|
|
246
|
+
const lockPid = parseInt(lockContent.trim(), 10);
|
|
247
|
+
// Check if process with PID still exists
|
|
248
|
+
try {
|
|
249
|
+
process.kill(lockPid, 0); // Signal 0 just checks if process exists
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Process doesn't exist, remove stale lock
|
|
253
|
+
await fs.unlink(lockPath);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Can't read lock file, try to remove it
|
|
259
|
+
try {
|
|
260
|
+
await fs.unlink(lockPath);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Can't remove lock file, continue waiting
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Lock is active, wait and retry
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
throw error; // Other error occurred
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw new FileLockError(filePath, new Error("Lock acquisition timeout"));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Get the base filename from a path
|
|
278
|
+
*/
|
|
279
|
+
function basename(path) {
|
|
280
|
+
return path.split(/[\\/]/).pop() || "";
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Generate a unique temporary file path
|
|
284
|
+
*/
|
|
285
|
+
function generateTempPath(originalPath, tmpDir) {
|
|
286
|
+
const timestamp = Date.now();
|
|
287
|
+
const random = Math.random().toString(36).substring(2);
|
|
288
|
+
const filename = `${timestamp}-${random}-${basename(originalPath)}.tmp`;
|
|
289
|
+
return join(tmpDir, filename);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Release a file lock
|
|
293
|
+
*/
|
|
294
|
+
async function releaseLock(filePath) {
|
|
295
|
+
const lockPath = `${filePath}.lock`;
|
|
296
|
+
try {
|
|
297
|
+
await fs.unlink(lockPath);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// Lock file might not exist or we don't have permission
|
|
301
|
+
// Don't throw error as this is cleanup
|
|
302
|
+
// Only log if it's not ENOENT (file not found)
|
|
303
|
+
if (error.code !== "ENOENT") {
|
|
304
|
+
console.warn(`Warning: Could not release lock for ${filePath}:`, error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|