@xagent-ai/cli 1.3.6 → 1.4.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/README.md +9 -0
- package/README_CN.md +9 -0
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -1
- package/dist/mcp.d.ts +8 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +53 -20
- package/dist/mcp.js.map +1 -1
- package/dist/sdk-output-adapter.d.ts +79 -0
- package/dist/sdk-output-adapter.d.ts.map +1 -1
- package/dist/sdk-output-adapter.js +118 -0
- package/dist/sdk-output-adapter.js.map +1 -1
- package/dist/session.d.ts +88 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +351 -5
- package/dist/session.js.map +1 -1
- package/dist/slash-commands.d.ts.map +1 -1
- package/dist/slash-commands.js +3 -5
- package/dist/slash-commands.js.map +1 -1
- package/dist/smart-approval.d.ts.map +1 -1
- package/dist/smart-approval.js +1 -0
- package/dist/smart-approval.js.map +1 -1
- package/dist/system-prompt-generator.d.ts +15 -1
- package/dist/system-prompt-generator.d.ts.map +1 -1
- package/dist/system-prompt-generator.js +36 -27
- package/dist/system-prompt-generator.js.map +1 -1
- package/dist/team-manager/index.d.ts +6 -0
- package/dist/team-manager/index.d.ts.map +1 -0
- package/dist/team-manager/index.js +6 -0
- package/dist/team-manager/index.js.map +1 -0
- package/dist/team-manager/message-broker.d.ts +128 -0
- package/dist/team-manager/message-broker.d.ts.map +1 -0
- package/dist/team-manager/message-broker.js +638 -0
- package/dist/team-manager/message-broker.js.map +1 -0
- package/dist/team-manager/team-coordinator.d.ts +45 -0
- package/dist/team-manager/team-coordinator.d.ts.map +1 -0
- package/dist/team-manager/team-coordinator.js +887 -0
- package/dist/team-manager/team-coordinator.js.map +1 -0
- package/dist/team-manager/team-store.d.ts +49 -0
- package/dist/team-manager/team-store.d.ts.map +1 -0
- package/dist/team-manager/team-store.js +436 -0
- package/dist/team-manager/team-store.js.map +1 -0
- package/dist/team-manager/teammate-spawner.d.ts +86 -0
- package/dist/team-manager/teammate-spawner.d.ts.map +1 -0
- package/dist/team-manager/teammate-spawner.js +605 -0
- package/dist/team-manager/teammate-spawner.js.map +1 -0
- package/dist/team-manager/types.d.ts +164 -0
- package/dist/team-manager/types.d.ts.map +1 -0
- package/dist/team-manager/types.js +27 -0
- package/dist/team-manager/types.js.map +1 -0
- package/dist/tools.d.ts +41 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +288 -32
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +20 -0
- package/src/mcp.ts +64 -25
- package/src/sdk-output-adapter.ts +177 -0
- package/src/session.ts +423 -15
- package/src/slash-commands.ts +3 -7
- package/src/smart-approval.ts +1 -0
- package/src/system-prompt-generator.ts +59 -26
- package/src/team-manager/index.ts +5 -0
- package/src/team-manager/message-broker.ts +751 -0
- package/src/team-manager/team-coordinator.ts +1117 -0
- package/src/team-manager/team-store.ts +558 -0
- package/src/team-manager/teammate-spawner.ts +800 -0
- package/src/team-manager/types.ts +206 -0
- package/src/tools.ts +316 -33
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import {
|
|
6
|
+
Team,
|
|
7
|
+
TeamMember,
|
|
8
|
+
TeamTask,
|
|
9
|
+
TaskCreateConfig,
|
|
10
|
+
MemberRole,
|
|
11
|
+
LEAD_PERMISSIONS,
|
|
12
|
+
TEAMMATE_PERMISSIONS,
|
|
13
|
+
MemberPermissions,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
const generateId = () => crypto.randomUUID();
|
|
17
|
+
|
|
18
|
+
// Lock timeout in milliseconds
|
|
19
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
20
|
+
// Maximum retries for acquiring lock
|
|
21
|
+
const MAX_LOCK_RETRIES = 50;
|
|
22
|
+
// Delay between lock retries in milliseconds
|
|
23
|
+
const LOCK_RETRY_DELAY_MS = 100;
|
|
24
|
+
|
|
25
|
+
interface LockInfo {
|
|
26
|
+
memberId: string;
|
|
27
|
+
timestamp: number;
|
|
28
|
+
pid: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TeamStore {
|
|
32
|
+
private baseDir: string;
|
|
33
|
+
private activeLocks: Map<string, NodeJS.Timeout> = new Map();
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
this.baseDir = path.join(os.homedir(), '.xagent', 'teams');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Acquire a file-based lock for a task.
|
|
41
|
+
* Uses exclusive file creation for atomicity across processes.
|
|
42
|
+
*/
|
|
43
|
+
private async acquireTaskLock(teamId: string, taskId: string, memberId: string): Promise<boolean> {
|
|
44
|
+
const lockDir = path.join(this.getTeamDir(teamId), 'locks');
|
|
45
|
+
await fs.mkdir(lockDir, { recursive: true });
|
|
46
|
+
const lockPath = path.join(lockDir, `${taskId}.lock`);
|
|
47
|
+
|
|
48
|
+
const lockInfo: LockInfo = {
|
|
49
|
+
memberId,
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
pid: process.pid,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (let attempt = 0; attempt < MAX_LOCK_RETRIES; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
// Try to create lock file exclusively (atomic operation)
|
|
57
|
+
const handle = await fs.open(lockPath, 'wx');
|
|
58
|
+
await handle.writeFile(JSON.stringify(lockInfo));
|
|
59
|
+
await handle.close();
|
|
60
|
+
|
|
61
|
+
// Set up auto-release timeout
|
|
62
|
+
const timeoutId = setTimeout(() => {
|
|
63
|
+
this.releaseTaskLock(teamId, taskId).catch(() => {});
|
|
64
|
+
}, LOCK_TIMEOUT_MS);
|
|
65
|
+
this.activeLocks.set(`${teamId}:${taskId}`, timeoutId);
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
} catch (error: any) {
|
|
69
|
+
if (error.code === 'EEXIST') {
|
|
70
|
+
// Lock file exists, check if it's stale
|
|
71
|
+
try {
|
|
72
|
+
const content = await fs.readFile(lockPath, 'utf-8');
|
|
73
|
+
const existingLock: LockInfo = JSON.parse(content);
|
|
74
|
+
|
|
75
|
+
// Check if lock is stale (older than timeout)
|
|
76
|
+
if (Date.now() - existingLock.timestamp > LOCK_TIMEOUT_MS) {
|
|
77
|
+
// Remove stale lock and retry
|
|
78
|
+
await fs.rm(lockPath, { force: true });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Lock is held by another process, wait and retry
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
|
|
84
|
+
continue;
|
|
85
|
+
} catch {
|
|
86
|
+
// Failed to read lock file, try to remove it
|
|
87
|
+
await fs.rm(lockPath, { force: true });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Release a task lock.
|
|
100
|
+
*/
|
|
101
|
+
private async releaseTaskLock(teamId: string, taskId: string): Promise<void> {
|
|
102
|
+
const lockPath = path.join(this.getTeamDir(teamId), 'locks', `${taskId}.lock`);
|
|
103
|
+
|
|
104
|
+
// Clear auto-release timeout
|
|
105
|
+
const lockKey = `${teamId}:${taskId}`;
|
|
106
|
+
const timeoutId = this.activeLocks.get(lockKey);
|
|
107
|
+
if (timeoutId) {
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
this.activeLocks.delete(lockKey);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Verify we own the lock before releasing
|
|
114
|
+
const content = await fs.readFile(lockPath, 'utf-8');
|
|
115
|
+
const lockInfo: LockInfo = JSON.parse(content);
|
|
116
|
+
|
|
117
|
+
// Only release if we own the lock (same pid or stale)
|
|
118
|
+
if (lockInfo.pid === process.pid || Date.now() - lockInfo.timestamp > LOCK_TIMEOUT_MS) {
|
|
119
|
+
await fs.rm(lockPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Lock file doesn't exist or is corrupted, ignore
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if a task is locked by another member.
|
|
128
|
+
*/
|
|
129
|
+
async isTaskLocked(teamId: string, taskId: string): Promise<boolean> {
|
|
130
|
+
const lockPath = path.join(this.getTeamDir(teamId), 'locks', `${taskId}.lock`);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const content = await fs.readFile(lockPath, 'utf-8');
|
|
134
|
+
const lockInfo: LockInfo = JSON.parse(content);
|
|
135
|
+
|
|
136
|
+
// Check if lock is stale
|
|
137
|
+
if (Date.now() - lockInfo.timestamp > LOCK_TIMEOUT_MS) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private getTeamDir(teamId: string): string {
|
|
148
|
+
return path.join(this.baseDir, teamId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async ensureBaseDir(): Promise<void> {
|
|
152
|
+
await fs.mkdir(this.baseDir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async createTeam(name: string, leadSessionId: string, workDir: string): Promise<Team> {
|
|
156
|
+
await this.ensureBaseDir();
|
|
157
|
+
|
|
158
|
+
const teamId = generateId();
|
|
159
|
+
const leadMemberId = generateId();
|
|
160
|
+
|
|
161
|
+
const leadMember: TeamMember = {
|
|
162
|
+
memberId: leadMemberId,
|
|
163
|
+
name: 'Lead',
|
|
164
|
+
role: 'lead',
|
|
165
|
+
memberRole: 'Team Lead',
|
|
166
|
+
status: 'active',
|
|
167
|
+
permissions: LEAD_PERMISSIONS,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const team: Team = {
|
|
171
|
+
teamId,
|
|
172
|
+
teamName: name,
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
leadSessionId,
|
|
175
|
+
leadMemberId,
|
|
176
|
+
members: [leadMember],
|
|
177
|
+
status: 'active',
|
|
178
|
+
workDir,
|
|
179
|
+
sharedTaskList: [],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const teamDir = this.getTeamDir(teamId);
|
|
183
|
+
await fs.mkdir(teamDir, { recursive: true });
|
|
184
|
+
await fs.mkdir(path.join(teamDir, 'tasks'), { recursive: true });
|
|
185
|
+
|
|
186
|
+
await this.saveTeam(team);
|
|
187
|
+
return team;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async getTeam(teamId: string): Promise<Team | null> {
|
|
191
|
+
try {
|
|
192
|
+
const configPath = path.join(this.getTeamDir(teamId), 'config.json');
|
|
193
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
194
|
+
return JSON.parse(content);
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async saveTeam(team: Team): Promise<void> {
|
|
201
|
+
await this.ensureBaseDir();
|
|
202
|
+
const configPath = path.join(this.getTeamDir(team.teamId), 'config.json');
|
|
203
|
+
await fs.writeFile(configPath, JSON.stringify(team, null, 2));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async addMember(
|
|
207
|
+
teamId: string,
|
|
208
|
+
member: Omit<TeamMember, 'permissions' | 'role'>
|
|
209
|
+
): Promise<TeamMember> {
|
|
210
|
+
const team = await this.getTeam(teamId);
|
|
211
|
+
if (!team) {
|
|
212
|
+
throw new Error(`Team ${teamId} not found`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const newMember: TeamMember = {
|
|
216
|
+
...member,
|
|
217
|
+
role: 'teammate',
|
|
218
|
+
permissions: TEAMMATE_PERMISSIONS,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const existingIndex = team.members.findIndex((m) => m.memberId === member.memberId);
|
|
222
|
+
if (existingIndex >= 0) {
|
|
223
|
+
team.members[existingIndex] = newMember;
|
|
224
|
+
} else {
|
|
225
|
+
team.members.push(newMember);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await this.saveTeam(team);
|
|
229
|
+
return newMember;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async updateMember(
|
|
233
|
+
teamId: string,
|
|
234
|
+
memberId: string,
|
|
235
|
+
updates: Partial<TeamMember>
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const team = await this.getTeam(teamId);
|
|
238
|
+
if (team) {
|
|
239
|
+
const member = team.members.find((m) => m.memberId === memberId);
|
|
240
|
+
if (member) {
|
|
241
|
+
Object.assign(member, updates);
|
|
242
|
+
await this.saveTeam(team);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async getMember(teamId: string, memberId: string): Promise<TeamMember | null> {
|
|
248
|
+
const team = await this.getTeam(teamId);
|
|
249
|
+
if (team) {
|
|
250
|
+
return team.members.find((m) => m.memberId === memberId) || null;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async deleteTeam(teamId: string): Promise<void> {
|
|
256
|
+
const teamDir = this.getTeamDir(teamId);
|
|
257
|
+
await fs.rm(teamDir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async listTeams(): Promise<Team[]> {
|
|
261
|
+
await this.ensureBaseDir();
|
|
262
|
+
const teams: Team[] = [];
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const dirs = await fs.readdir(this.baseDir);
|
|
266
|
+
for (const dir of dirs) {
|
|
267
|
+
const team = await this.getTeam(dir);
|
|
268
|
+
if (team) {
|
|
269
|
+
teams.push(team);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return teams.sort((a, b) => b.createdAt - a.createdAt);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async createTask(
|
|
280
|
+
teamId: string,
|
|
281
|
+
config: TaskCreateConfig,
|
|
282
|
+
createdBy: string
|
|
283
|
+
): Promise<TeamTask> {
|
|
284
|
+
const team = await this.getTeam(teamId);
|
|
285
|
+
if (!team) {
|
|
286
|
+
throw new Error(`Team ${teamId} not found`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Validate that all dependencies exist
|
|
290
|
+
if (config.dependencies && config.dependencies.length > 0) {
|
|
291
|
+
const existingTasks = await this.getTasks(teamId);
|
|
292
|
+
const existingTaskIds = new Set(existingTasks.map(t => t.taskId));
|
|
293
|
+
const invalidDeps = config.dependencies.filter(depId => !existingTaskIds.has(depId));
|
|
294
|
+
|
|
295
|
+
if (invalidDeps.length > 0) {
|
|
296
|
+
throw new Error(`Invalid task dependencies: tasks not found: ${invalidDeps.join(', ')}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check for circular dependencies
|
|
300
|
+
const depCheck = this.checkCircularDependency(config.dependencies, [], existingTasks);
|
|
301
|
+
if (depCheck.hasCycle) {
|
|
302
|
+
throw new Error(`Circular dependency detected: ${depCheck.cyclePath?.join(' -> ')}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const task: TeamTask = {
|
|
307
|
+
taskId: generateId(),
|
|
308
|
+
teamId,
|
|
309
|
+
title: config.title,
|
|
310
|
+
description: config.description,
|
|
311
|
+
status: 'pending',
|
|
312
|
+
assignee: config.assignee,
|
|
313
|
+
dependencies: config.dependencies || [],
|
|
314
|
+
priority: config.priority || 'medium',
|
|
315
|
+
createdAt: Date.now(),
|
|
316
|
+
updatedAt: Date.now(),
|
|
317
|
+
createdBy,
|
|
318
|
+
version: 1,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const tasksDir = path.join(this.getTeamDir(teamId), 'tasks');
|
|
322
|
+
await fs.mkdir(tasksDir, { recursive: true });
|
|
323
|
+
|
|
324
|
+
const taskPath = path.join(tasksDir, `${task.taskId}.json`);
|
|
325
|
+
await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
|
|
326
|
+
|
|
327
|
+
team.sharedTaskList.push(task);
|
|
328
|
+
await this.saveTeam(team);
|
|
329
|
+
|
|
330
|
+
return task;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check for circular dependencies in task graph.
|
|
335
|
+
*/
|
|
336
|
+
private checkCircularDependency(
|
|
337
|
+
taskIds: string[],
|
|
338
|
+
visited: string[],
|
|
339
|
+
allTasks: TeamTask[]
|
|
340
|
+
): { hasCycle: boolean; cyclePath?: string[] } {
|
|
341
|
+
const taskMap = new Map(allTasks.map(t => [t.taskId, t]));
|
|
342
|
+
|
|
343
|
+
for (const taskId of taskIds) {
|
|
344
|
+
if (visited.includes(taskId)) {
|
|
345
|
+
return { hasCycle: true, cyclePath: [...visited, taskId] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const task = taskMap.get(taskId);
|
|
349
|
+
if (task && task.dependencies.length > 0) {
|
|
350
|
+
const result = this.checkCircularDependency(
|
|
351
|
+
task.dependencies,
|
|
352
|
+
[...visited, taskId],
|
|
353
|
+
allTasks
|
|
354
|
+
);
|
|
355
|
+
if (result.hasCycle) {
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { hasCycle: false };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async getTask(teamId: string, taskId: string): Promise<TeamTask | null> {
|
|
365
|
+
try {
|
|
366
|
+
const taskPath = path.join(this.getTeamDir(teamId), 'tasks', `${taskId}.json`);
|
|
367
|
+
const content = await fs.readFile(taskPath, 'utf-8');
|
|
368
|
+
return JSON.parse(content);
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async updateTask(
|
|
375
|
+
teamId: string,
|
|
376
|
+
taskId: string,
|
|
377
|
+
updates: Partial<TeamTask>,
|
|
378
|
+
expectedVersion?: number
|
|
379
|
+
): Promise<TeamTask | null> {
|
|
380
|
+
const team = await this.getTeam(teamId);
|
|
381
|
+
if (!team) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const taskIndex = team.sharedTaskList.findIndex((t) => t.taskId === taskId);
|
|
386
|
+
if (taskIndex < 0) {
|
|
387
|
+
const task = await this.getTask(teamId, taskId);
|
|
388
|
+
if (!task) return null;
|
|
389
|
+
|
|
390
|
+
if (expectedVersion !== undefined && task.version !== expectedVersion) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
Object.assign(task, updates, { updatedAt: Date.now(), version: task.version + 1 });
|
|
395
|
+
const taskPath = path.join(this.getTeamDir(teamId), 'tasks', `${taskId}.json`);
|
|
396
|
+
await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
|
|
397
|
+
return task;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (expectedVersion !== undefined && team.sharedTaskList[taskIndex].version !== expectedVersion) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
Object.assign(team.sharedTaskList[taskIndex], updates, { updatedAt: Date.now(), version: (team.sharedTaskList[taskIndex].version || 0) + 1 });
|
|
405
|
+
const taskPath = path.join(this.getTeamDir(teamId), 'tasks', `${taskId}.json`);
|
|
406
|
+
await fs.writeFile(taskPath, JSON.stringify(team.sharedTaskList[taskIndex], null, 2));
|
|
407
|
+
await this.saveTeam(team);
|
|
408
|
+
|
|
409
|
+
return team.sharedTaskList[taskIndex];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async deleteTask(teamId: string, taskId: string, expectedVersion?: number): Promise<boolean> {
|
|
413
|
+
const team = await this.getTeam(teamId);
|
|
414
|
+
if (!team) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const taskIndex = team.sharedTaskList.findIndex((t) => t.taskId === taskId);
|
|
419
|
+
if (taskIndex < 0) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (expectedVersion !== undefined && team.sharedTaskList[taskIndex].version !== expectedVersion) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
team.sharedTaskList.splice(taskIndex, 1);
|
|
428
|
+
|
|
429
|
+
const taskPath = path.join(this.getTeamDir(teamId), 'tasks', `${taskId}.json`);
|
|
430
|
+
await fs.rm(taskPath, { force: true });
|
|
431
|
+
|
|
432
|
+
await this.saveTeam(team);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async getTasks(teamId: string): Promise<TeamTask[]> {
|
|
437
|
+
const team = await this.getTeam(teamId);
|
|
438
|
+
if (team) {
|
|
439
|
+
return team.sharedTaskList.sort((a, b) => a.createdAt - b.createdAt);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const tasksDir = path.join(this.getTeamDir(teamId), 'tasks');
|
|
443
|
+
const tasks: TeamTask[] = [];
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const files = await fs.readdir(tasksDir);
|
|
447
|
+
for (const file of files) {
|
|
448
|
+
if (file.endsWith('.json')) {
|
|
449
|
+
const content = await fs.readFile(path.join(tasksDir, file), 'utf-8');
|
|
450
|
+
tasks.push(JSON.parse(content));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// directory doesn't exist
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return tasks.sort((a, b) => a.createdAt - b.createdAt);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async getAvailableTasks(teamId: string): Promise<TeamTask[]> {
|
|
461
|
+
const tasks = await this.getTasks(teamId);
|
|
462
|
+
const taskMap = new Map(tasks.map((t) => [t.taskId, t]));
|
|
463
|
+
|
|
464
|
+
return tasks.filter((task) => {
|
|
465
|
+
if (task.status !== 'pending') return false;
|
|
466
|
+
return task.dependencies.every((depId) => {
|
|
467
|
+
const dep = taskMap.get(depId);
|
|
468
|
+
return dep && dep.status === 'completed';
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async claimTask(
|
|
474
|
+
teamId: string,
|
|
475
|
+
taskId: string,
|
|
476
|
+
memberId: string
|
|
477
|
+
): Promise<TeamTask | null> {
|
|
478
|
+
// Acquire lock first to prevent race conditions
|
|
479
|
+
const lockAcquired = await this.acquireTaskLock(teamId, taskId, memberId);
|
|
480
|
+
if (!lockAcquired) {
|
|
481
|
+
throw new Error(`Task ${taskId} is currently being claimed by another member. Please try again.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
// Re-read task after acquiring lock to ensure we have latest state
|
|
486
|
+
const task = await this.getTask(teamId, taskId);
|
|
487
|
+
if (!task) return null;
|
|
488
|
+
|
|
489
|
+
if (task.status !== 'pending') {
|
|
490
|
+
throw new Error(`Task ${taskId} is not available (status: ${task.status})`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Re-check dependencies after acquiring lock
|
|
494
|
+
const tasks = await this.getTasks(teamId);
|
|
495
|
+
const taskMap = new Map(tasks.map((t) => [t.taskId, t]));
|
|
496
|
+
|
|
497
|
+
const uncompletedDeps = task.dependencies.filter((depId) => {
|
|
498
|
+
const dep = taskMap.get(depId);
|
|
499
|
+
return dep && dep.status !== 'completed';
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (uncompletedDeps.length > 0) {
|
|
503
|
+
throw new Error(`Task has uncompleted dependencies: ${uncompletedDeps.join(', ')}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Update task with version check
|
|
507
|
+
const result = await this.updateTask(teamId, taskId, {
|
|
508
|
+
status: 'in_progress',
|
|
509
|
+
assignee: memberId,
|
|
510
|
+
}, task.version);
|
|
511
|
+
|
|
512
|
+
if (!result) {
|
|
513
|
+
throw new Error(`Task ${taskId} was already claimed by another member`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return result;
|
|
517
|
+
} finally {
|
|
518
|
+
// Always release the lock
|
|
519
|
+
await this.releaseTaskLock(teamId, taskId);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async getTeamStatus(teamId: string): Promise<{
|
|
524
|
+
team: Team | null;
|
|
525
|
+
memberCount: number;
|
|
526
|
+
activeTaskCount: number;
|
|
527
|
+
completedTaskCount: number;
|
|
528
|
+
}> {
|
|
529
|
+
const team = await this.getTeam(teamId);
|
|
530
|
+
if (!team) {
|
|
531
|
+
return { team: null, memberCount: 0, activeTaskCount: 0, completedTaskCount: 0 };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const tasks = await this.getTasks(teamId);
|
|
535
|
+
const activeTaskCount = tasks.filter((t) => t.status === 'in_progress').length;
|
|
536
|
+
const completedTaskCount = tasks.filter((t) => t.status === 'completed').length;
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
team,
|
|
540
|
+
memberCount: team.members.length,
|
|
541
|
+
activeTaskCount,
|
|
542
|
+
completedTaskCount,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
getPermissionsForRole(role: MemberRole): MemberPermissions {
|
|
547
|
+
return role === 'lead' ? LEAD_PERMISSIONS : TEAMMATE_PERMISSIONS;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let teamStoreInstance: TeamStore | null = null;
|
|
552
|
+
|
|
553
|
+
export function getTeamStore(): TeamStore {
|
|
554
|
+
if (!teamStoreInstance) {
|
|
555
|
+
teamStoreInstance = new TeamStore();
|
|
556
|
+
}
|
|
557
|
+
return teamStoreInstance;
|
|
558
|
+
}
|