agent-ecosystem/src/main/services/team/TeamTaskWriter.ts
iliya 0c2f70b2b2 feat: implement in-app project editor with CodeMirror integration
- Added architectural plan and iteration plan for the in-app project editor.
- Introduced new components for the editor, including CodeEditorOverlay, FileTreePanel, and EditorTabsPanel.
- Established state management using Zustand for editor state persistence.
- Implemented IPC channels for file operations and editor functionality.
- Enhanced TeamDetailView with a button to open the editor overlay.
- Conducted reuse analysis for existing components to optimize codebase integration.
2026-02-27 22:36:06 +02:00

287 lines
9.2 KiB
TypeScript

import { getTasksBasePath } from '@main/utils/pathDecoder';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types';
const taskWriteLocks = new Map<string, Promise<void>>();
async function withTaskLock<T>(taskPath: string, fn: () => Promise<T>): Promise<T> {
const prev = taskWriteLocks.get(taskPath) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((resolve) => {
release = resolve;
});
taskWriteLocks.set(taskPath, mine);
await prev;
try {
return await fn();
} finally {
release();
if (taskWriteLocks.get(taskPath) === mine) {
taskWriteLocks.delete(taskPath);
}
}
}
export class TeamTaskWriter {
async createTask(teamName: string, task: TeamTask): Promise<void> {
const tasksDir = path.join(getTasksBasePath(), teamName);
await fs.promises.mkdir(tasksDir, { recursive: true });
const taskPath = path.join(tasksDir, `${task.id}.json`);
await withTaskLock(taskPath, async () => {
try {
await fs.promises.access(taskPath, fs.constants.F_OK);
throw new Error(`Task already exists: ${task.id}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
// Ensure CLI-compatible format: description, blocks, blockedBy are required
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
const cliCompatibleTask: TeamTask = {
...task,
description: task.description ?? '',
blocks: task.blocks ?? [],
blockedBy: task.blockedBy ?? [],
related: task.related ?? [],
createdAt: task.createdAt ?? new Date().toISOString(),
};
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
if (verifyTask.id !== task.id) {
throw new Error(`Task create verification failed: ${task.id}`);
}
});
}
async addBlocksEntry(
teamName: string,
targetTaskId: string,
blockedTaskId: string
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${targetTaskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // Target task doesn't exist — skip silently
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
const blocks = task.blocks ?? [];
if (!blocks.includes(blockedTaskId)) {
task.blocks = [...blocks, blockedTaskId];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
}
});
}
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
task.status = status;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
if (verifyTask.status !== status) {
throw new Error(`Task status update verification failed: ${taskId}`);
}
});
}
async updateOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
if (owner) {
task.owner = owner;
} else {
delete task.owner;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async softDelete(teamName: string, taskId: string): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
task.status = 'deleted';
task.deletedAt = new Date().toISOString();
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
if (verifyTask.status !== 'deleted') {
throw new Error(`Task soft-delete verification failed: ${taskId}`);
}
});
}
async restoreTask(teamName: string, taskId: string): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
task.status = 'pending';
delete task.deletedAt;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async updateFields(
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
if (fields.subject !== undefined) {
task.subject = fields.subject;
}
if (fields.description !== undefined) {
task.description = fields.description;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async setNeedsClarification(
teamName: string,
taskId: string,
value: 'lead' | 'user' | null
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as Record<string, unknown>;
if (value) {
task.needsClarification = value;
} else {
delete task.needsClarification;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async addComment(
teamName: string,
taskId: string,
text: string,
options?: { id?: string; author?: string; createdAt?: string }
): Promise<TaskComment> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = {
id: options?.id ?? randomUUID(),
author: options?.author ?? 'user',
text,
createdAt: options?.createdAt ?? new Date().toISOString(),
};
await withTaskLock(taskPath, async () => {
const raw = await fs.promises.readFile(taskPath, 'utf8');
const task = JSON.parse(raw) as Record<string, unknown>;
const existing = Array.isArray(task.comments) ? (task.comments as TaskComment[]) : [];
// Dedup by ID — skip if comment with same ID already exists
if (existing.some((c) => c.id === comment.id)) {
return;
}
task.comments = [...existing, comment];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
const verified = JSON.parse(verifyRaw) as Record<string, unknown>;
const verifiedComments = Array.isArray(verified.comments)
? (verified.comments as TaskComment[])
: [];
if (!verifiedComments.some((c) => c.id === comment.id)) {
throw new Error(`Comment write verification failed for task: ${taskId}`);
}
});
return comment;
}
}