refactor: streamline task handling and remove legacy support
- Removed legacy overlay review state handling from taskStore, simplifying task normalization processes. - Updated task retrieval methods to directly use normalized task data without fallback to legacy kanban states. - Eliminated outdated tests related to legacy kanban overlay, focusing on modern task management mechanisms. - Refactored TeamDataService and TaskBoundaryParser to enhance clarity and maintainability by removing unnecessary complexity. - Updated related types and interfaces to reflect the removal of legacy support.
This commit is contained in:
parent
52ddbb2916
commit
df457eb9cd
17 changed files with 156 additions and 3299 deletions
|
|
@ -72,33 +72,10 @@ function normalizeTaskReviewState(value) {
|
||||||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOverlayReviewState(overlayTasks, taskId) {
|
|
||||||
if (!overlayTasks || typeof overlayTasks !== 'object') {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = overlayTasks[String(taskId)];
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.column === 'review' || entry.column === 'approved' ? entry.column : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function withCompatibleReviewState(task, overlayTasks) {
|
|
||||||
const explicit = normalizeTaskReviewState(task.reviewState);
|
|
||||||
return explicit === 'none' ? { ...task, reviewState: getOverlayReviewState(overlayTasks, task.id) } : task;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listRawTasks(paths) {
|
function listRawTasks(paths) {
|
||||||
ensureDir(paths.tasksDir);
|
ensureDir(paths.tasksDir);
|
||||||
const entries = fs.readdirSync(paths.tasksDir);
|
const entries = fs.readdirSync(paths.tasksDir);
|
||||||
const out = [];
|
const out = [];
|
||||||
const overlayState = readJson(paths.kanbanPath, null);
|
|
||||||
const overlayTasks =
|
|
||||||
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
|
||||||
? overlayState.tasks
|
|
||||||
: null;
|
|
||||||
|
|
||||||
for (const fileName of entries) {
|
for (const fileName of entries) {
|
||||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||||
|
|
@ -107,7 +84,7 @@ function listRawTasks(paths) {
|
||||||
if (!rawTask) continue;
|
if (!rawTask) continue;
|
||||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||||
try {
|
try {
|
||||||
out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks));
|
out.push(normalizeTask(rawTask, filePath));
|
||||||
} catch {
|
} catch {
|
||||||
// Skip unreadable task rows.
|
// Skip unreadable task rows.
|
||||||
}
|
}
|
||||||
|
|
@ -165,12 +142,7 @@ function readTask(paths, taskRef, options = {}) {
|
||||||
if (!rawTask) {
|
if (!rawTask) {
|
||||||
throw new Error(`Task not found: ${String(taskRef)}`);
|
throw new Error(`Task not found: ${String(taskRef)}`);
|
||||||
}
|
}
|
||||||
const overlayState = readJson(paths.kanbanPath, null);
|
return normalizeTask(rawTask, taskPath);
|
||||||
const overlayTasks =
|
|
||||||
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
|
||||||
? overlayState.tasks
|
|
||||||
: null;
|
|
||||||
return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusTransition(history, from, to, actor, timestamp) {
|
function createStatusTransition(history, from, to, actor, timestamp) {
|
||||||
|
|
|
||||||
|
|
@ -194,39 +194,6 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(second.linkedCommentsCreated).toBe(0);
|
expect(second.linkedCommentsCreated).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives reviewState from legacy kanban overlay and tolerates corrupt kanban state', () => {
|
|
||||||
const claudeDir = makeClaudeDir();
|
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
|
||||||
const task = controller.tasks.createTask({ subject: 'Legacy review task' });
|
|
||||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
|
||||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
|
||||||
delete rawTask.reviewState;
|
|
||||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
|
||||||
|
|
||||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
kanbanPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
teamName: 'my-team',
|
|
||||||
reviewers: [],
|
|
||||||
tasks: {
|
|
||||||
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('review');
|
|
||||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('review');
|
|
||||||
|
|
||||||
fs.writeFileSync(kanbanPath, '{broken-json');
|
|
||||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
|
|
||||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('none');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
|
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Check if sessions have subagent files
|
* - Check if sessions have subagent files
|
||||||
* - List subagent files for a session
|
* - List subagent files for a session
|
||||||
* - Handle both NEW and OLD subagent directory structures:
|
* - Handle the canonical subagent directory structure:
|
||||||
* - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
* - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||||
* - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
|
|
||||||
* - Determine subagent ownership for OLD structure
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||||
|
|
@ -91,110 +89,25 @@ export class SubagentLocator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all subagent files for a session from both NEW and OLD structures.
|
* Lists all subagent files for a session from the canonical session-local structure.
|
||||||
* Returns NEW structure files first, then OLD structure files.
|
|
||||||
*
|
|
||||||
* @param projectId - The project ID
|
|
||||||
* @param sessionId - The session ID
|
|
||||||
* @returns Promise resolving to array of file paths
|
|
||||||
*/
|
*/
|
||||||
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||||
const allFiles: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
|
|
||||||
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
||||||
if (await this.fsProvider.exists(newSubagentsPath)) {
|
if (await this.fsProvider.exists(newSubagentsPath)) {
|
||||||
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
||||||
const newFiles = entries
|
return entries
|
||||||
.filter(
|
.filter(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
|
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
|
||||||
)
|
)
|
||||||
.map((entry) => path.join(newSubagentsPath, entry.name));
|
.map((entry) => path.join(newSubagentsPath, entry.name));
|
||||||
allFiles.push(...newFiles);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
|
logger.error(`Error scanning subagent structure for session ${sessionId}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return [];
|
||||||
// Scan OLD structure: {projectId}/agent-*.jsonl
|
|
||||||
// Must filter by sessionId since all sessions share the same project root
|
|
||||||
const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId);
|
|
||||||
allFiles.push(...oldFiles);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return allFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets subagent files from project root (OLD structure).
|
|
||||||
* Scans {projectId}/agent-*.jsonl files and filters by sessionId.
|
|
||||||
*
|
|
||||||
* In the OLD structure, all subagent files are in the project root,
|
|
||||||
* so we must read each file's first line to check if it belongs to the session.
|
|
||||||
*
|
|
||||||
* @param projectId - The project ID
|
|
||||||
* @param sessionId - The session ID
|
|
||||||
* @returns Promise resolving to array of file paths
|
|
||||||
*/
|
|
||||||
async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
|
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await this.fsProvider.readdir(projectPath);
|
|
||||||
const agentFiles = entries
|
|
||||||
.filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
|
|
||||||
.map((entry) => path.join(projectPath, entry.name));
|
|
||||||
|
|
||||||
// Filter files by checking if their sessionId matches
|
|
||||||
const matchingFiles: string[] = [];
|
|
||||||
for (const filePath of agentFiles) {
|
|
||||||
if (await this.subagentBelongsToSession(filePath, sessionId)) {
|
|
||||||
matchingFiles.push(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchingFiles;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error reading project root for subagent files:`, error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a subagent file belongs to a specific session by reading its first line.
|
|
||||||
* Subagent files have a sessionId field that points to the parent session.
|
|
||||||
*
|
|
||||||
* @param filePath - Path to the subagent file
|
|
||||||
* @param sessionId - The session ID to check
|
|
||||||
* @returns Promise resolving to true if the subagent belongs to the session
|
|
||||||
*/
|
|
||||||
async subagentBelongsToSession(filePath: string, sessionId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Read just the first line to check sessionId
|
|
||||||
const content = await this.fsProvider.readFile(filePath);
|
|
||||||
const firstNewline = content.indexOf('\n');
|
|
||||||
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
|
|
||||||
|
|
||||||
if (!firstLine.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = JSON.parse(firstLine) as { sessionId?: string };
|
|
||||||
return entry.sessionId === sessionId;
|
|
||||||
} catch (error) {
|
|
||||||
// If we can't read or parse the file, don't include it - log for debugging
|
|
||||||
logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,9 @@ interface ToolUseInfo {
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Historical fallback for legacy CLI sessions only.
|
|
||||||
* New runtime sessions are expected to emit structured task updates via MCP/TaskUpdate.
|
|
||||||
*/
|
|
||||||
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
|
|
||||||
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
|
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
|
||||||
|
|
||||||
type DetectedMechanism = 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
|
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
||||||
|
|
||||||
function pickDetectedMechanism(
|
function pickDetectedMechanism(
|
||||||
current: DetectedMechanism,
|
current: DetectedMechanism,
|
||||||
|
|
@ -46,9 +41,8 @@ function pickDetectedMechanism(
|
||||||
): DetectedMechanism {
|
): DetectedMechanism {
|
||||||
const priority = {
|
const priority = {
|
||||||
none: 0,
|
none: 0,
|
||||||
teamctl: 1,
|
TaskUpdate: 1,
|
||||||
TaskUpdate: 2,
|
mcp: 2,
|
||||||
mcp: 3,
|
|
||||||
} as const;
|
} as const;
|
||||||
return priority[next] > priority[current] ? next : current;
|
return priority[next] > priority[current] ? next : current;
|
||||||
}
|
}
|
||||||
|
|
@ -123,13 +117,6 @@ export class TaskBoundaryParser {
|
||||||
boundaries.push(...mcpBounds);
|
boundaries.push(...mcpBounds);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy CLI fallback for historical JSONL rows.
|
|
||||||
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
|
|
||||||
if (teamctlBounds.length > 0) {
|
|
||||||
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl');
|
|
||||||
boundaries.push(...teamctlBounds);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Пропускаем невалидные строки
|
// Пропускаем невалидные строки
|
||||||
}
|
}
|
||||||
|
|
@ -290,63 +277,6 @@ export class TaskBoundaryParser {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Historical fallback: detect legacy teamctl task commands in Bash tool_use blocks.
|
|
||||||
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
|
|
||||||
*/
|
|
||||||
private extractTeamctlBoundaries(
|
|
||||||
content: unknown[],
|
|
||||||
lineNumber: number,
|
|
||||||
timestamp: string
|
|
||||||
): TaskBoundary[] {
|
|
||||||
const results: TaskBoundary[] = [];
|
|
||||||
|
|
||||||
for (const block of content) {
|
|
||||||
if (!block || typeof block !== 'object') continue;
|
|
||||||
const b = block as Record<string, unknown>;
|
|
||||||
if (b.type !== 'tool_use') continue;
|
|
||||||
|
|
||||||
const rawName = typeof b.name === 'string' ? b.name : '';
|
|
||||||
const toolName = rawName.replace(/^proxy_/, '');
|
|
||||||
if (toolName !== 'Bash') continue;
|
|
||||||
|
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
|
||||||
if (!input) continue;
|
|
||||||
|
|
||||||
const command = typeof input.command === 'string' ? input.command : '';
|
|
||||||
if (!command.includes('teamctl')) continue;
|
|
||||||
|
|
||||||
const match = TEAMCTL_TASK_REGEX.exec(command);
|
|
||||||
if (!match) continue;
|
|
||||||
|
|
||||||
const action = match[1]; // start | complete | set-status
|
|
||||||
const taskId = match[2];
|
|
||||||
|
|
||||||
let event: TaskBoundaryEvent = null;
|
|
||||||
if (action === 'start') event = 'start';
|
|
||||||
else if (action === 'complete') event = 'complete';
|
|
||||||
else if (action === 'set-status') {
|
|
||||||
// set-status может быть start или complete — определяем по аргументам
|
|
||||||
if (command.includes('in_progress') || command.includes('in-progress')) event = 'start';
|
|
||||||
else if (command.includes('completed') || command.includes('done')) event = 'complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event) {
|
|
||||||
const toolUseId = typeof b.id === 'string' ? b.id : undefined;
|
|
||||||
results.push({
|
|
||||||
taskId,
|
|
||||||
event,
|
|
||||||
lineNumber,
|
|
||||||
timestamp,
|
|
||||||
mechanism: 'teamctl',
|
|
||||||
toolUseId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вычислить scopes для каждой задачи на основе границ.
|
* Вычислить scopes для каждой задачи на основе границ.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { getToolsBasePath } from '@main/utils/pathDecoder';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
|
||||||
|
|
||||||
const TOOL_FILE_NAME = 'teamctl.js';
|
|
||||||
|
|
||||||
function getCandidateLegacyCliPaths(): string[] {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
return [
|
|
||||||
path.join(cwd, 'agent-teams-controller', 'src', 'legacy', 'teamctl.cli.js'),
|
|
||||||
path.join(cwd, 'agent-teams-controller', 'dist', 'legacy', 'teamctl.cli.js'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readExtractedTeamctlSource(): Promise<string> {
|
|
||||||
for (const candidatePath of getCandidateLegacyCliPaths()) {
|
|
||||||
try {
|
|
||||||
return await fs.promises.readFile(candidatePath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Extracted teamctl CLI source not found in agent-teams-controller package');
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TeamAgentToolsInstaller {
|
|
||||||
async ensureInstalled(): Promise<string> {
|
|
||||||
const toolsDir = getToolsBasePath();
|
|
||||||
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
|
|
||||||
await fs.promises.mkdir(toolsDir, { recursive: true });
|
|
||||||
|
|
||||||
const desired = await readExtractedTeamctlSource();
|
|
||||||
let current: string | null = null;
|
|
||||||
try {
|
|
||||||
current = await fs.promises.readFile(toolPath, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current === desired) {
|
|
||||||
return toolPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
await atomicWriteAsync(toolPath, desired);
|
|
||||||
return toolPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -107,20 +107,13 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTaskReviewState(
|
private resolveTaskReviewState(
|
||||||
task: Pick<TeamTask, 'id' | 'reviewState'>,
|
task: Pick<TeamTask, 'reviewState'>
|
||||||
kanbanState?: Pick<KanbanState, 'tasks'>
|
|
||||||
): 'none' | 'review' | 'approved' {
|
): 'none' | 'review' | 'approved' {
|
||||||
const explicit = normalizeReviewState(task.reviewState);
|
return normalizeReviewState(task.reviewState);
|
||||||
if (explicit !== 'none') {
|
|
||||||
return explicit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlay = kanbanState?.tasks?.[task.id]?.column;
|
|
||||||
return overlay === 'review' || overlay === 'approved' ? overlay : 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban {
|
private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanbanState);
|
const reviewState = this.resolveTaskReviewState(task);
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
reviewState,
|
reviewState,
|
||||||
|
|
@ -172,8 +165,7 @@ export class TeamDataService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const info = teamInfoMap.get(task.teamName)!;
|
const info = teamInfoMap.get(task.teamName)!;
|
||||||
const kanban = kanbanByTeam.get(task.teamName);
|
const reviewState = this.resolveTaskReviewState(task);
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanban);
|
|
||||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||||
|
|
||||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||||
|
|
@ -348,8 +340,6 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeMessages) {
|
if (includeMessages) {
|
||||||
this.ensureStableMessageIds(messages);
|
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||||
|
|
@ -413,19 +403,17 @@ export class TeamDataService {
|
||||||
reviewers: [],
|
reviewers: [],
|
||||||
tasks: {},
|
tasks: {},
|
||||||
};
|
};
|
||||||
let canRunKanbanGc = true;
|
|
||||||
try {
|
try {
|
||||||
kanbanState = await this.kanbanManager.getState(teamName);
|
kanbanState = await this.kanbanManager.getState(teamName);
|
||||||
} catch {
|
} catch {
|
||||||
warnings.push('Kanban state failed to load');
|
warnings.push('Kanban state failed to load');
|
||||||
canRunKanbanGc = false;
|
|
||||||
}
|
}
|
||||||
mark('kanbanState');
|
mark('kanbanState');
|
||||||
|
|
||||||
mark('kanbanGc');
|
mark('kanbanGc');
|
||||||
|
|
||||||
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
|
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||||
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
this.attachKanbanCompatibility(task)
|
||||||
);
|
);
|
||||||
|
|
||||||
const members = this.memberResolver.resolveMembers(
|
const members = this.memberResolver.resolveMembers(
|
||||||
|
|
@ -444,7 +432,7 @@ export class TeamDataService {
|
||||||
mark('syncComments');
|
mark('syncComments');
|
||||||
|
|
||||||
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
|
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||||
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
this.attachKanbanCompatibility(task)
|
||||||
);
|
);
|
||||||
|
|
||||||
let processes: TeamProcess[] = [];
|
let processes: TeamProcess[] = [];
|
||||||
|
|
@ -1114,66 +1102,6 @@ export class TeamDataService {
|
||||||
return normalized === leadName.trim().toLowerCase() || normalized === 'team-lead';
|
return normalized === leadName.trim().toLowerCase() || normalized === 'team-lead';
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessageIdPart(value: string | undefined, fallback = 'unknown'): string {
|
|
||||||
const normalized = (value ?? '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\r\n/g, '\n')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^\p{L}\p{N}_-]/gu, '')
|
|
||||||
.slice(0, 40);
|
|
||||||
return normalized || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Older inbox/sent-message records may not include messageId. Assign deterministic ids
|
|
||||||
* so renderer keys remain stable across refreshes, filtering, and live updates.
|
|
||||||
*/
|
|
||||||
private ensureStableMessageIds(messages: InboxMessage[]): void {
|
|
||||||
const seenAssignedIds = new Set<string>();
|
|
||||||
const missingIdOccurrences = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
const existingId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
|
||||||
if (existingId) {
|
|
||||||
seenAssignedIds.add(existingId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textPrefix = this.normalizeMessageIdPart(message.text?.slice(0, 80), 'empty');
|
|
||||||
const fingerprint = [
|
|
||||||
message.source ?? 'unknown',
|
|
||||||
message.timestamp,
|
|
||||||
message.from,
|
|
||||||
message.to ?? '',
|
|
||||||
message.leadSessionId ?? '',
|
|
||||||
textPrefix,
|
|
||||||
].join('\0');
|
|
||||||
const occurrence = (missingIdOccurrences.get(fingerprint) ?? 0) + 1;
|
|
||||||
missingIdOccurrences.set(fingerprint, occurrence);
|
|
||||||
|
|
||||||
let syntheticId = [
|
|
||||||
'synthetic-msg',
|
|
||||||
this.normalizeMessageIdPart(message.source, 'unknown'),
|
|
||||||
this.normalizeMessageIdPart(message.timestamp, 'unknown'),
|
|
||||||
this.normalizeMessageIdPart(message.from, 'unknown'),
|
|
||||||
this.normalizeMessageIdPart(message.to, 'none'),
|
|
||||||
textPrefix,
|
|
||||||
occurrence,
|
|
||||||
].join('-');
|
|
||||||
|
|
||||||
if (seenAssignedIds.has(syntheticId)) {
|
|
||||||
let collision = 2;
|
|
||||||
while (seenAssignedIds.has(`${syntheticId}-dup${collision}`)) {
|
|
||||||
collision++;
|
|
||||||
}
|
|
||||||
syntheticId = `${syntheticId}-dup${collision}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.messageId = syntheticId;
|
|
||||||
seenAssignedIds.add(syntheticId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendDirectToLead(
|
async sendDirectToLead(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
|
|
||||||
|
|
@ -309,8 +309,7 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fast marker probe for task-related logs.
|
* Fast marker probe for task-related logs.
|
||||||
* Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching
|
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||||
* only as historical fallback for old JSONL data.
|
|
||||||
*/
|
*/
|
||||||
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
|
@ -336,11 +335,6 @@ export class TeamMemberLogsFinder {
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
|
|
||||||
rl.close();
|
|
||||||
stream.destroy();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore read errors
|
// ignore read errors
|
||||||
|
|
@ -508,18 +502,6 @@ export class TeamMemberLogsFinder {
|
||||||
return typeof raw === 'string' ? raw.trim() : null;
|
return typeof raw === 'string' ? raw.trim() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesTeamctlCommand = (command: string): boolean => {
|
|
||||||
if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false;
|
|
||||||
|
|
||||||
const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command);
|
|
||||||
const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim();
|
|
||||||
if (cmdTeam?.toLowerCase() !== teamLower) return false;
|
|
||||||
|
|
||||||
const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command);
|
|
||||||
const cmdTaskId = taskMatch?.[1];
|
|
||||||
return Boolean(cmdTaskId && cmdTaskId === taskIdStr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchesTeamMentionText = (text: string): boolean => {
|
const matchesTeamMentionText = (text: string): boolean => {
|
||||||
const t = text.toLowerCase();
|
const t = text.toLowerCase();
|
||||||
if (!t.includes(teamLower)) return false;
|
if (!t.includes(teamLower)) return false;
|
||||||
|
|
@ -631,16 +613,6 @@ export class TeamMemberLogsFinder {
|
||||||
taskSeenWithoutTeam = true;
|
taskSeenWithoutTeam = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic CLI match: teamctl command line (Bash tool).
|
|
||||||
if (toolName === 'Bash') {
|
|
||||||
const command = typeof input.command === 'string' ? input.command : '';
|
|
||||||
if (command && matchesTeamctlCommand(command)) {
|
|
||||||
rl.close();
|
|
||||||
stream.destroy();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (teamSeen && taskSeenWithoutTeam) {
|
if (teamSeen && taskSeenWithoutTeam) {
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,6 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
|
||||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
const KNOWN_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
|
||||||
'image/png',
|
|
||||||
'image/jpeg',
|
|
||||||
'image/gif',
|
|
||||||
'image/webp',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export class TeamTaskAttachmentStore {
|
export class TeamTaskAttachmentStore {
|
||||||
private assertSafePathSegment(label: string, value: string): void {
|
private assertSafePathSegment(label: string, value: string): void {
|
||||||
if (
|
if (
|
||||||
|
|
@ -42,7 +35,7 @@ export class TeamTaskAttachmentStore {
|
||||||
|
|
||||||
private sanitizeStoredFilename(original: string): string {
|
private sanitizeStoredFilename(original: string): string {
|
||||||
const raw = String(original ?? '').trim();
|
const raw = String(original ?? '').trim();
|
||||||
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
|
const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : '';
|
||||||
const cleaned = base
|
const cleaned = base
|
||||||
.replace(/\0/g, '')
|
.replace(/\0/g, '')
|
||||||
.replace(/[\r\n\t]/g, ' ')
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
|
@ -65,42 +58,15 @@ export class TeamTaskAttachmentStore {
|
||||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
|
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map known MIME types to file extension (legacy storage format). */
|
|
||||||
private mimeToExt(mimeType: string): string {
|
|
||||||
switch (mimeType) {
|
|
||||||
case 'image/png':
|
|
||||||
return '.png';
|
|
||||||
case 'image/jpeg':
|
|
||||||
return '.jpg';
|
|
||||||
case 'image/gif':
|
|
||||||
return '.gif';
|
|
||||||
case 'image/webp':
|
|
||||||
return '.webp';
|
|
||||||
default:
|
|
||||||
return '.bin';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findAttachmentFilePath(
|
private async findAttachmentFilePath(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
mimeType?: string
|
_mimeType?: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const dir = this.getTaskDir(teamName, taskId);
|
const dir = this.getTaskDir(teamName, taskId);
|
||||||
|
|
||||||
// 1) Prefer legacy path for known image types (older storage format).
|
// Canonical format: "<id>--<filename>"
|
||||||
if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) {
|
|
||||||
const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`);
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.stat(legacy);
|
|
||||||
if (stat.isFile()) return legacy;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) New format: "<id>--<filename>"
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.promises.readdir(dir);
|
const entries = await fs.promises.readdir(dir);
|
||||||
const prefix = `${attachmentId}--`;
|
const prefix = `${attachmentId}--`;
|
||||||
|
|
@ -108,13 +74,6 @@ export class TeamTaskAttachmentStore {
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
return path.join(dir, matches[0]);
|
return path.join(dir, matches[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Fallback: any file starting with "<id>." (covers legacy when mimeType missing/wrong).
|
|
||||||
const dotPrefix = `${attachmentId}.`;
|
|
||||||
const dotMatches = entries.filter((e) => e.startsWith(dotPrefix));
|
|
||||||
if (dotMatches.length > 0) {
|
|
||||||
return path.join(dir, dotMatches[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
// Non-directory or other IO errors should surface.
|
// Non-directory or other IO errors should surface.
|
||||||
|
|
|
||||||
|
|
@ -103,24 +103,10 @@ export class TeamTaskReader {
|
||||||
if (metadata?._internal === true) {
|
if (metadata?._internal === true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// CLI sometimes writes "title" instead of "subject" — normalize
|
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||||
const subject =
|
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
|
||||||
typeof parsed.subject === 'string'
|
|
||||||
? parsed.subject
|
|
||||||
: typeof parsed.title === 'string'
|
|
||||||
? parsed.title
|
|
||||||
: '';
|
|
||||||
// Resolve createdAt: prefer JSON field, fallback to fs.stat (reuse fileStat from above)
|
|
||||||
let createdAt: string | undefined;
|
|
||||||
let updatedAt: string | undefined;
|
let updatedAt: string | undefined;
|
||||||
if (typeof parsed.createdAt === 'string') {
|
|
||||||
createdAt = parsed.createdAt;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (!createdAt) {
|
|
||||||
const bt = fileStat.birthtime.getTime();
|
|
||||||
createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString();
|
|
||||||
}
|
|
||||||
updatedAt = fileStat.mtime.toISOString();
|
updatedAt = fileStat.mtime.toISOString();
|
||||||
} catch {
|
} catch {
|
||||||
/* leave undefined */
|
/* leave undefined */
|
||||||
|
|
@ -354,12 +340,7 @@ export class TeamTaskReader {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject =
|
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||||
typeof parsed.subject === 'string'
|
|
||||||
? parsed.subject
|
|
||||||
: typeof parsed.title === 'string'
|
|
||||||
? parsed.title
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const task: TeamTask = {
|
const task: TeamTask = {
|
||||||
id:
|
id:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
||||||
export { MemberStatsComputer } from './MemberStatsComputer';
|
export { MemberStatsComputer } from './MemberStatsComputer';
|
||||||
export { ReviewApplierService } from './ReviewApplierService';
|
export { ReviewApplierService } from './ReviewApplierService';
|
||||||
export { TaskBoundaryParser } from './TaskBoundaryParser';
|
export { TaskBoundaryParser } from './TaskBoundaryParser';
|
||||||
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
|
||||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||||
export { TeamConfigReader } from './TeamConfigReader';
|
export { TeamConfigReader } from './TeamConfigReader';
|
||||||
export { TeamDataService } from './TeamDataService';
|
export { TeamDataService } from './TeamDataService';
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
indentOnInput,
|
indentOnInput,
|
||||||
syntaxHighlighting,
|
syntaxHighlighting,
|
||||||
} from '@codemirror/language';
|
} from '@codemirror/language';
|
||||||
import { type Diagnostic, linter, lintGutter } from '@codemirror/lint';
|
import { lintGutter } from '@codemirror/lint';
|
||||||
import { search, searchKeymap } from '@codemirror/search';
|
import { search, searchKeymap } from '@codemirror/search';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
|
||||||
import { AlertTriangle, Check, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, Check, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { AppConfig } from '@renderer/types/data';
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
|
|
@ -40,31 +40,6 @@ import type { AppConfig } from '@renderer/types/data';
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 800;
|
const SAVE_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// JSON Linter
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const jsonLinter = linter((view: EditorView) => {
|
|
||||||
const diagnostics: Diagnostic[] = [];
|
|
||||||
const text = view.state.doc.toString();
|
|
||||||
try {
|
|
||||||
JSON.parse(text);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SyntaxError) {
|
|
||||||
const match = /position (\d+)/.exec(e.message);
|
|
||||||
const pos = match ? parseInt(match[1], 10) : 0;
|
|
||||||
const safePos = Math.min(pos, text.length);
|
|
||||||
diagnostics.push({
|
|
||||||
from: safePos,
|
|
||||||
to: Math.min(safePos + 1, text.length),
|
|
||||||
severity: 'error',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return diagnostics;
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,23 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import {
|
||||||
|
bracketMatching,
|
||||||
|
foldGutter,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from '@codemirror/language';
|
||||||
|
import { lintGutter } from '@codemirror/lint';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
import {
|
||||||
|
EditorView,
|
||||||
|
highlightActiveLine,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
|
||||||
|
|
||||||
interface MembersJsonEditorProps {
|
interface MembersJsonEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -14,6 +27,16 @@ interface MembersJsonEditorProps {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const membersEditorTheme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontSize: '12px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const MembersJsonEditor = ({
|
export const MembersJsonEditor = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -31,30 +54,25 @@ export const MembersJsonEditor = ({
|
||||||
doc: value,
|
doc: value,
|
||||||
extensions: [
|
extensions: [
|
||||||
json(),
|
json(),
|
||||||
oneDark,
|
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
|
highlightActiveLineGutter(),
|
||||||
|
highlightActiveLine(),
|
||||||
history(),
|
history(),
|
||||||
|
foldGutter(),
|
||||||
|
indentOnInput(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
|
jsonLinter,
|
||||||
|
lintGutter(),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
|
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
|
||||||
|
baseEditorTheme,
|
||||||
|
membersEditorTheme,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
onChangeRef.current(update.state.doc.toString());
|
onChangeRef.current(update.state.doc.toString());
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
EditorView.theme({
|
|
||||||
'&': {
|
|
||||||
fontSize: '12px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
},
|
|
||||||
'.cm-scroller': {
|
|
||||||
overflow: 'auto',
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Base CodeMirror 6 theme using CSS variables.
|
* Base CodeMirror 6 theme and shared extensions.
|
||||||
*
|
*
|
||||||
* Extracted from CodeMirrorDiffView.tsx — shared between diff view and project editor.
|
* Extracted from CodeMirrorDiffView.tsx — shared between diff view, config editor, and member editor.
|
||||||
* Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView.
|
* Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type Diagnostic, linter } from '@codemirror/lint';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
/** Base editor theme — general styling without diff-specific rules */
|
/** Base editor theme — general styling without diff-specific rules */
|
||||||
|
|
@ -50,4 +51,88 @@ export const baseEditorTheme = EditorView.theme({
|
||||||
'.cm-selectionBackground': {
|
'.cm-selectionBackground': {
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ---- Lint tooltips & diagnostics (dark-theme aware) ---- */
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
border: '1px solid var(--color-border-emphasis)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||||
|
},
|
||||||
|
'.cm-tooltip-lint': {
|
||||||
|
padding: '0',
|
||||||
|
borderRadius: '6px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
'.cm-diagnostic': {
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderLeft: '3px solid transparent',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
},
|
||||||
|
'.cm-diagnostic-error': {
|
||||||
|
borderLeftColor: '#ef4444',
|
||||||
|
color: '#fca5a5',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||||
|
},
|
||||||
|
'.cm-diagnostic-warning': {
|
||||||
|
borderLeftColor: '#f59e0b',
|
||||||
|
color: '#fcd34d',
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||||
|
},
|
||||||
|
'.cm-diagnostic-info': {
|
||||||
|
borderLeftColor: '#3b82f6',
|
||||||
|
color: '#93c5fd',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||||||
|
},
|
||||||
|
'.cm-lintRange-error': {
|
||||||
|
backgroundImage: 'none',
|
||||||
|
textDecoration: 'underline wavy #ef4444',
|
||||||
|
textUnderlineOffset: '3px',
|
||||||
|
},
|
||||||
|
'.cm-lintRange-warning': {
|
||||||
|
backgroundImage: 'none',
|
||||||
|
textDecoration: 'underline wavy #f59e0b',
|
||||||
|
textUnderlineOffset: '3px',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---- Search panel (dark-theme aware) ---- */
|
||||||
|
'.cm-panels': {
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
},
|
||||||
|
'.cm-panel input, .cm-panel button': {
|
||||||
|
color: 'inherit',
|
||||||
|
},
|
||||||
|
'.cm-panel input[type="text"]': {
|
||||||
|
backgroundColor: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared JSON linter — validates JSON and reports syntax errors inline. */
|
||||||
|
export const jsonLinter = linter((view: EditorView) => {
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
const text = view.state.doc.toString();
|
||||||
|
try {
|
||||||
|
JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
const match = /position (\d+)/.exec(e.message);
|
||||||
|
const pos = match ? parseInt(match[1], 10) : 0;
|
||||||
|
const safePos = Math.min(pos, text.length);
|
||||||
|
diagnostics.push({
|
||||||
|
from: safePos,
|
||||||
|
to: Math.min(safePos + 1, text.length),
|
||||||
|
severity: 'error',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export interface TaskBoundary {
|
||||||
event: 'start' | 'complete';
|
event: 'start' | 'complete';
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
mechanism: 'TaskUpdate' | 'teamctl' | 'mcp';
|
mechanism: 'TaskUpdate' | 'mcp';
|
||||||
toolUseId?: string;
|
toolUseId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ export interface TaskBoundariesResult {
|
||||||
boundaries: TaskBoundary[];
|
boundaries: TaskBoundary[];
|
||||||
scopes: TaskChangeScope[];
|
scopes: TaskChangeScope[];
|
||||||
isSingleTaskSession: boolean;
|
isSingleTaskSession: boolean;
|
||||||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
|
detectedMechanism: 'TaskUpdate' | 'mcp' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */
|
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */
|
||||||
|
|
|
||||||
|
|
@ -64,55 +64,7 @@ describe('TaskBoundaryParser', () => {
|
||||||
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
|
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to legacy teamctl bash parsing for historical logs', async () => {
|
it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
|
||||||
const jsonlPath = path.join(tmpDir, 'teamctl.jsonl');
|
|
||||||
await fs.writeFile(
|
|
||||||
jsonlPath,
|
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
timestamp: '2026-03-01T10:00:00.000Z',
|
|
||||||
type: 'assistant',
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'tool_use',
|
|
||||||
id: 'tool-1',
|
|
||||||
name: 'Bash',
|
|
||||||
input: { command: 'node "teamctl.js" --team demo task start 123' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
JSON.stringify({
|
|
||||||
timestamp: '2026-03-01T10:10:00.000Z',
|
|
||||||
type: 'assistant',
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'tool_use',
|
|
||||||
id: 'tool-2',
|
|
||||||
name: 'Bash',
|
|
||||||
input: { command: 'node "teamctl.js" --team demo task set-status 123 completed' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
].join('\n') + '\n',
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
|
|
||||||
|
|
||||||
expect(result.detectedMechanism).toBe('teamctl');
|
|
||||||
expect(result.boundaries).toHaveLength(2);
|
|
||||||
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
|
|
||||||
expect(result.boundaries.every((entry) => entry.mechanism === 'teamctl')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers structured mechanisms over legacy teamctl in mixed logs', async () => {
|
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
||||||
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
|
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
|
|
@ -155,5 +107,7 @@ describe('TaskBoundaryParser', () => {
|
||||||
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
|
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
|
||||||
|
|
||||||
expect(result.detectedMechanism).toBe('mcp');
|
expect(result.detectedMechanism).toBe('mcp');
|
||||||
|
expect(result.boundaries).toHaveLength(1);
|
||||||
|
expect(result.boundaries[0]?.mechanism).toBe('mcp');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -589,7 +589,7 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects structured task markers while keeping legacy teamctl matching as fallback', async () => {
|
it('detects structured task markers and ignores legacy teamctl command lines', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-'));
|
||||||
|
|
||||||
const structuredPath = path.join(tmpDir, 'structured.jsonl');
|
const structuredPath = path.join(tmpDir, 'structured.jsonl');
|
||||||
|
|
@ -646,7 +646,7 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
const finder = new TeamMemberLogsFinder();
|
const finder = new TeamMemberLogsFinder();
|
||||||
|
|
||||||
await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true);
|
await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true);
|
||||||
await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(true);
|
await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(false);
|
||||||
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
|
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue