Skip to content

Commit 23377e1

Browse files
Passing tests
1 parent 1673da1 commit 23377e1

File tree

6 files changed

+257
-75
lines changed

6 files changed

+257
-75
lines changed

src/client/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,12 @@ program
554554
}
555555
} catch (err: unknown) {
556556
if (err instanceof Error) {
557-
console.error(chalk.yellow(`Warning: ${err.message}`));
557+
// Check for API key related errors and format them appropriately
558+
if (err.message.includes('API key') || err.message.includes('authentication') || err.message.includes('unauthorized')) {
559+
console.error(chalk.red(`Error: ${err.message}`));
560+
} else {
561+
console.error(chalk.yellow(`Warning: ${err.message}`));
562+
}
558563
} else {
559564
const normalized = normalizeError(err);
560565
console.error(chalk.red(formatCliError(normalized)));

src/server/FileSystemService.ts

Lines changed: 89 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export interface InitializedTaskData {
1212

1313
export class FileSystemService {
1414
private filePath: string;
15+
// Simple in-memory queue to prevent concurrent file operations
16+
private operationInProgress: boolean = false;
17+
private operationQueue: (() => void)[] = [];
1518

1619
constructor(filePath: string) {
1720
this.filePath = filePath;
@@ -37,21 +40,74 @@ export class FileSystemService {
3740
}
3841
}
3942

43+
/**
44+
* Queue a file operation to prevent concurrent access
45+
* @param operation The operation to perform
46+
* @returns Promise that resolves when the operation completes
47+
*/
48+
private async queueOperation<T>(operation: () => Promise<T>): Promise<T> {
49+
// If another operation is in progress, wait for it to complete
50+
if (this.operationInProgress) {
51+
return new Promise<T>((resolve, reject) => {
52+
this.operationQueue.push(() => {
53+
this.executeOperation(operation).then(resolve).catch(reject);
54+
});
55+
});
56+
}
57+
58+
return this.executeOperation(operation);
59+
}
60+
61+
/**
62+
* Execute a file operation with mutex protection
63+
* @param operation The operation to perform
64+
* @returns Promise that resolves when the operation completes
65+
*/
66+
private async executeOperation<T>(operation: () => Promise<T>): Promise<T> {
67+
this.operationInProgress = true;
68+
try {
69+
return await operation();
70+
} finally {
71+
this.operationInProgress = false;
72+
// Process the next operation in the queue, if any
73+
const nextOperation = this.operationQueue.shift();
74+
if (nextOperation) {
75+
nextOperation();
76+
}
77+
}
78+
}
79+
4080
/**
4181
* Loads and initializes task data from the JSON file
4282
*/
4383
public async loadAndInitializeTasks(): Promise<InitializedTaskData> {
44-
const data = await this.loadTasks();
45-
const { maxProjectId, maxTaskId } = this.calculateMaxIds(data);
46-
47-
return {
48-
data,
49-
maxProjectId,
50-
maxTaskId
51-
};
84+
return this.queueOperation(async () => {
85+
const data = await this.loadTasks();
86+
const { maxProjectId, maxTaskId } = this.calculateMaxIds(data);
87+
88+
return {
89+
data,
90+
maxProjectId,
91+
maxTaskId
92+
};
93+
});
5294
}
5395

54-
private calculateMaxIds(data: TaskManagerFile): { maxProjectId: number; maxTaskId: number } {
96+
/**
97+
* Explicitly reloads task data from the disk
98+
* This is useful when the file may have been changed by another process
99+
* @returns The latest task data from disk
100+
*/
101+
public async reloadTasks(): Promise<TaskManagerFile> {
102+
return this.queueOperation(async () => {
103+
return this.loadTasks();
104+
});
105+
}
106+
107+
/**
108+
* Calculate max IDs from task data
109+
*/
110+
public calculateMaxIds(data: TaskManagerFile): { maxProjectId: number; maxTaskId: number } {
55111
const allTaskIds: number[] = [];
56112
const allProjectIds: number[] = [];
57113

@@ -89,32 +145,35 @@ export class FileSystemService {
89145
}
90146

91147
/**
92-
* Saves task data to the JSON file
148+
* Saves task data to the JSON file with an in-memory mutex to prevent concurrent writes
93149
*/
94150
public async saveTasks(data: TaskManagerFile): Promise<void> {
95-
try {
96-
// Ensure directory exists before writing
97-
const dir = dirname(this.filePath);
98-
await mkdir(dir, { recursive: true });
99-
100-
await writeFile(
101-
this.filePath,
102-
JSON.stringify(data, null, 2),
103-
"utf-8"
104-
);
105-
} catch (error) {
106-
if (error instanceof Error && error.message.includes("EROFS")) {
151+
return this.queueOperation(async () => {
152+
try {
153+
// Ensure directory exists before writing
154+
const dir = dirname(this.filePath);
155+
await mkdir(dir, { recursive: true });
156+
157+
// Write to the file
158+
await writeFile(
159+
this.filePath,
160+
JSON.stringify(data, null, 2),
161+
"utf-8"
162+
);
163+
} catch (error) {
164+
if (error instanceof Error && error.message.includes("EROFS")) {
165+
throw createError(
166+
ErrorCode.ReadOnlyFileSystem,
167+
"Cannot save tasks: read-only file system",
168+
{ originalError: error }
169+
);
170+
}
107171
throw createError(
108-
ErrorCode.ReadOnlyFileSystem,
109-
"Cannot save tasks: read-only file system",
172+
ErrorCode.FileWriteError,
173+
"Failed to save tasks file",
110174
{ originalError: error }
111175
);
112176
}
113-
throw createError(
114-
ErrorCode.FileWriteError,
115-
"Failed to save tasks file",
116-
{ originalError: error }
117-
);
118-
}
177+
});
119178
}
120179
}

src/server/TaskManager.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ export class TaskManager {
3232
await this.initialized;
3333
}
3434

35+
/**
36+
* Reloads data from disk
37+
* This is helpful when the task file might have been modified by another process
38+
* Used internally before read operations
39+
*/
40+
public async reloadFromDisk(): Promise<void> {
41+
const data = await this.fileSystemService.reloadTasks();
42+
this.data = data;
43+
const { maxProjectId, maxTaskId } = this.fileSystemService.calculateMaxIds(data);
44+
this.projectCounter = maxProjectId;
45+
this.taskCounter = maxTaskId;
46+
}
47+
3548
private async saveTasks() {
3649
await this.fileSystemService.saveTasks(this.data);
3750
}
@@ -43,6 +56,8 @@ export class TaskManager {
4356
autoApprove?: boolean
4457
) {
4558
await this.ensureInitialized();
59+
// Reload before creating to ensure counters are up-to-date
60+
await this.reloadFromDisk();
4661
this.projectCounter += 1;
4762
const projectId = `proj-${this.projectCounter}`;
4863

@@ -221,6 +236,9 @@ export class TaskManager {
221236

222237
public async getNextTask(projectId: string): Promise<StandardResponse> {
223238
await this.ensureInitialized();
239+
// Reload from disk to ensure we have the latest data
240+
await this.reloadFromDisk();
241+
224242
const proj = this.data.projects.find((p) => p.projectId === projectId);
225243
if (!proj) {
226244
throw createError(
@@ -267,6 +285,8 @@ export class TaskManager {
267285

268286
public async approveTaskCompletion(projectId: string, taskId: string) {
269287
await this.ensureInitialized();
288+
// Reload before modifying
289+
await this.reloadFromDisk();
270290
const proj = this.data.projects.find((p) => p.projectId === projectId);
271291
if (!proj) {
272292
throw createError(
@@ -307,6 +327,8 @@ export class TaskManager {
307327

308328
public async approveProjectCompletion(projectId: string) {
309329
await this.ensureInitialized();
330+
// Reload before modifying
331+
await this.reloadFromDisk();
310332
const proj = this.data.projects.find((p) => p.projectId === projectId);
311333
if (!proj) {
312334
throw createError(
@@ -349,6 +371,9 @@ export class TaskManager {
349371

350372
public async openTaskDetails(taskId: string) {
351373
await this.ensureInitialized();
374+
// Reload from disk to ensure we have the latest data
375+
await this.reloadFromDisk();
376+
352377
for (const proj of this.data.projects) {
353378
const target = proj.tasks.find((t) => t.id === taskId);
354379
if (target) {
@@ -376,6 +401,8 @@ export class TaskManager {
376401

377402
public async listProjects(state?: TaskState) {
378403
await this.ensureInitialized();
404+
// Reload from disk to ensure we have the latest data
405+
await this.reloadFromDisk();
379406

380407
let filteredProjects = [...this.data.projects];
381408

@@ -409,6 +436,8 @@ export class TaskManager {
409436

410437
public async listTasks(projectId?: string, state?: TaskState) {
411438
await this.ensureInitialized();
439+
// Reload from disk to ensure we have the latest data
440+
await this.reloadFromDisk();
412441

413442
// If projectId is provided, verify the project exists
414443
if (projectId) {
@@ -462,6 +491,8 @@ export class TaskManager {
462491
tasks: { title: string; description: string; toolRecommendations?: string; ruleRecommendations?: string }[]
463492
) {
464493
await this.ensureInitialized();
494+
// Reload before modifying
495+
await this.reloadFromDisk();
465496
const proj = this.data.projects.find((p) => p.projectId === projectId);
466497
if (!proj) {
467498
throw createError(
@@ -512,6 +543,8 @@ export class TaskManager {
512543
}
513544
) {
514545
await this.ensureInitialized();
546+
// Reload before modifying
547+
await this.reloadFromDisk();
515548
const project = this.data.projects.find((p) => p.projectId === projectId);
516549
if (!project) {
517550
throw createError(
@@ -542,6 +575,8 @@ export class TaskManager {
542575

543576
public async deleteTask(projectId: string, taskId: string) {
544577
await this.ensureInitialized();
578+
// Reload before modifying
579+
await this.reloadFromDisk();
545580
const proj = this.data.projects.find((p) => p.projectId === projectId);
546581
if (!proj) {
547582
throw createError(
@@ -581,6 +616,9 @@ export class TaskManager {
581616
tasks: Task[];
582617
}>> {
583618
await this.ensureInitialized();
619+
// Reload from disk to ensure we have the latest data
620+
await this.reloadFromDisk();
621+
584622
const project = this.data.projects.find(p => p.projectId === projectId);
585623
if (!project) {
586624
throw createError(

tests/integration/TaskManager.test.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('TaskManager Integration', () => {
1414
tempDir = path.join(os.tmpdir(), `task-manager-integration-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`);
1515
await fs.mkdir(tempDir, { recursive: true });
1616
testFilePath = path.join(tempDir, 'test-tasks.json');
17-
17+
1818
// Initialize the server with the test file path
1919
server = new TaskManager(testFilePath);
2020
});
@@ -171,14 +171,20 @@ describe('TaskManager Integration', () => {
171171
const taskId2 = createResult.data.tasks[1].id;
172172

173173
// 2. Try to approve project before tasks are done (should fail)
174-
await expect(server.approveProjectCompletion(projectId)).rejects.toThrow('Not all tasks are done');
174+
await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({
175+
code: 'ERR_3003',
176+
message: 'Not all tasks are done'
177+
});
175178

176179
// 3. Mark tasks as done
177180
await server.updateTask(projectId, taskId1, { status: 'done', completedDetails: 'Task 1 completed details' });
178181
await server.updateTask(projectId, taskId2, { status: 'done', completedDetails: 'Task 2 completed details' });
179182

180183
// 4. Try to approve project before tasks are approved (should fail)
181-
await expect(server.approveProjectCompletion(projectId)).rejects.toThrow('Not all done tasks are approved');
184+
await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({
185+
code: 'ERR_3004',
186+
message: 'Not all done tasks are approved'
187+
});
182188

183189
// 5. Approve tasks
184190
await server.approveTaskCompletion(projectId, taskId1);
@@ -195,7 +201,10 @@ describe('TaskManager Integration', () => {
195201
expect(completedProject).toBeDefined();
196202

197203
// 8. Try to approve again (should fail)
198-
await expect(server.approveProjectCompletion(projectId)).rejects.toThrow('Project is already completed');
204+
await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({
205+
code: 'ERR_3001',
206+
message: 'Project is already completed'
207+
});
199208
});
200209

201210
it("should handle complex project and task state transitions", async () => {
@@ -250,8 +259,6 @@ describe('TaskManager Integration', () => {
250259
});
251260

252261
it("should handle tool/rule recommendations end-to-end", async () => {
253-
const server = new TaskManager(testFilePath);
254-
255262
// Create a project with tasks that have recommendations
256263
const response = await server.createProject("Test Project", [
257264
{
@@ -380,10 +387,17 @@ describe('TaskManager Integration', () => {
380387
expect(projectState.data.projects.find(p => p.projectId === project.projectId)).toBeDefined();
381388
});
382389

383-
it("should handle multiple concurrent server instances", async () => {
384-
// Create two server instances pointing to the same file
385-
const server1 = new TaskManager(testFilePath);
386-
const server2 = new TaskManager(testFilePath);
390+
it("multiple concurrent server instances should synchronize data", async () => {
391+
// Create a unique file path just for this test
392+
const uniqueTestFilePath = path.join(tempDir, `concurrent-test-${Date.now()}.json`);
393+
394+
// Create two server instances that would typically be in different processes
395+
const server1 = new TaskManager(uniqueTestFilePath);
396+
const server2 = new TaskManager(uniqueTestFilePath);
397+
398+
// Ensure both servers are fully initialized
399+
await server1["initialized"];
400+
await server2["initialized"];
387401

388402
// Create a project with server1
389403
const projectResponse = await server1.createProject(
@@ -411,7 +425,7 @@ describe('TaskManager Integration', () => {
411425
});
412426
await server1.approveTaskCompletion(project.projectId, project.tasks[0].id);
413427

414-
// Verify completion with server2
428+
// Verify completion with server2 (it should automatically reload latest data)
415429
const completedTasks = await server2.listTasks(project.projectId, "completed");
416430
expect(completedTasks.status).toBe('success');
417431
expect(completedTasks.data.tasks!.length).toBe(1);
@@ -420,10 +434,9 @@ describe('TaskManager Integration', () => {
420434
const completionResult = await server2.approveProjectCompletion(project.projectId);
421435
expect(completionResult.status).toBe('success');
422436

423-
// Verify with server1
437+
// Verify with server1 (it should automatically reload latest data)
424438
const projectState = await server1.listProjects("completed");
425439
expect(projectState.status).toBe('success');
426440
expect(projectState.data.projects.find(p => p.projectId === project.projectId)).toBeDefined();
427441
});
428442
});
429-

0 commit comments

Comments
 (0)