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:
iliya 2026-03-08 00:24:48 +02:00
parent 52ddbb2916
commit df457eb9cd
17 changed files with 156 additions and 3299 deletions

View file

@ -72,33 +72,10 @@ function normalizeTaskReviewState(value) {
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) {
ensureDir(paths.tasksDir);
const entries = fs.readdirSync(paths.tasksDir);
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) {
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
@ -107,7 +84,7 @@ function listRawTasks(paths) {
if (!rawTask) continue;
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
try {
out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks));
out.push(normalizeTask(rawTask, filePath));
} catch {
// Skip unreadable task rows.
}
@ -165,12 +142,7 @@ function readTask(paths, taskRef, options = {}) {
if (!rawTask) {
throw new Error(`Task not found: ${String(taskRef)}`);
}
const overlayState = readJson(paths.kanbanPath, null);
const overlayTasks =
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
? overlayState.tasks
: null;
return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks);
return normalizeTask(rawTask, taskPath);
}
function createStatusTransition(history, from, to, actor, timestamp) {

View file

@ -194,39 +194,6 @@ describe('agent-teams-controller API', () => {
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', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -4,10 +4,8 @@
* Responsibilities:
* - Check if sessions have subagent files
* - List subagent files for a session
* - Handle both NEW and OLD subagent directory structures:
* - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
* - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
* - Determine subagent ownership for OLD structure
* - Handle the canonical subagent directory structure:
* - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
*/
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.
* 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
* Lists all subagent files for a session from the canonical session-local structure.
*/
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
const allFiles: string[] = [];
try {
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (await this.fsProvider.exists(newSubagentsPath)) {
const entries = await this.fsProvider.readdir(newSubagentsPath);
const newFiles = entries
return entries
.filter(
(entry) =>
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
)
.map((entry) => path.join(newSubagentsPath, entry.name));
allFiles.push(...newFiles);
}
} catch (error) {
logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
logger.error(`Error scanning subagent structure for session ${sessionId}:`, error);
}
try {
// 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;
}
return [];
}
/**

View file

@ -31,14 +31,9 @@ interface ToolUseInfo {
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']);
type DetectedMechanism = 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
function pickDetectedMechanism(
current: DetectedMechanism,
@ -46,9 +41,8 @@ function pickDetectedMechanism(
): DetectedMechanism {
const priority = {
none: 0,
teamctl: 1,
TaskUpdate: 2,
mcp: 3,
TaskUpdate: 1,
mcp: 2,
} as const;
return priority[next] > priority[current] ? next : current;
}
@ -123,13 +117,6 @@ export class TaskBoundaryParser {
boundaries.push(...mcpBounds);
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 {
// Пропускаем невалидные строки
}
@ -290,63 +277,6 @@ export class TaskBoundaryParser {
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 для каждой задачи на основе границ.
*

View file

@ -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;
}
}

View file

@ -107,20 +107,13 @@ export class TeamDataService {
}
private resolveTaskReviewState(
task: Pick<TeamTask, 'id' | 'reviewState'>,
kanbanState?: Pick<KanbanState, 'tasks'>
task: Pick<TeamTask, 'reviewState'>
): 'none' | 'review' | 'approved' {
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
return explicit;
}
const overlay = kanbanState?.tasks?.[task.id]?.column;
return overlay === 'review' || overlay === 'approved' ? overlay : 'none';
return normalizeReviewState(task.reviewState);
}
private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task, kanbanState);
private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task);
return {
...task,
reviewState,
@ -172,8 +165,7 @@ export class TeamDataService {
continue;
}
const info = teamInfoMap.get(task.teamName)!;
const kanban = kanbanByTeam.get(task.teamName);
const reviewState = this.resolveTaskReviewState(task, kanban);
const reviewState = this.resolveTaskReviewState(task);
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
@ -348,8 +340,6 @@ export class TeamDataService {
}
if (includeMessages) {
this.ensureStableMessageIds(messages);
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
@ -413,19 +403,17 @@ export class TeamDataService {
reviewers: [],
tasks: {},
};
let canRunKanbanGc = true;
try {
kanbanState = await this.kanbanManager.getState(teamName);
} catch {
warnings.push('Kanban state failed to load');
canRunKanbanGc = false;
}
mark('kanbanState');
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
this.attachKanbanCompatibility(task)
);
const members = this.memberResolver.resolveMembers(
@ -444,7 +432,7 @@ export class TeamDataService {
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
this.attachKanbanCompatibility(task)
);
let processes: TeamProcess[] = [];
@ -1114,66 +1102,6 @@ export class TeamDataService {
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(
teamName: string,
leadName: string,

View file

@ -309,8 +309,7 @@ export class TeamMemberLogsFinder {
/**
* Fast marker probe for task-related logs.
* Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching
* only as historical fallback for old JSONL data.
* Prefer structured MCP/TaskUpdate markers for modern sessions.
*/
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
@ -336,11 +335,6 @@ export class TeamMemberLogsFinder {
stream.destroy();
return true;
}
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
rl.close();
stream.destroy();
return true;
}
}
} catch {
// ignore read errors
@ -508,18 +502,6 @@ export class TeamMemberLogsFinder {
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 t = text.toLowerCase();
if (!t.includes(teamLower)) return false;
@ -631,16 +613,6 @@ export class TeamMemberLogsFinder {
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) {

View file

@ -10,13 +10,6 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
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 {
private assertSafePathSegment(label: string, value: string): void {
if (
@ -42,7 +35,7 @@ export class TeamTaskAttachmentStore {
private sanitizeStoredFilename(original: string): string {
const raw = String(original ?? '').trim();
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : '';
const cleaned = base
.replace(/\0/g, '')
.replace(/[\r\n\t]/g, ' ')
@ -65,42 +58,15 @@ export class TeamTaskAttachmentStore {
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(
teamName: string,
taskId: string,
attachmentId: string,
mimeType?: string
_mimeType?: string
): Promise<string | null> {
const dir = this.getTaskDir(teamName, taskId);
// 1) Prefer legacy path for known image types (older storage format).
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>"
// Canonical format: "<id>--<filename>"
try {
const entries = await fs.promises.readdir(dir);
const prefix = `${attachmentId}--`;
@ -108,13 +74,6 @@ export class TeamTaskAttachmentStore {
if (matches.length > 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) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
// Non-directory or other IO errors should surface.

View file

@ -103,24 +103,10 @@ export class TeamTaskReader {
if (metadata?._internal === true) {
continue;
}
// CLI sometimes writes "title" instead of "subject" — normalize
const subject =
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;
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
let updatedAt: string | undefined;
if (typeof parsed.createdAt === 'string') {
createdAt = parsed.createdAt;
}
try {
if (!createdAt) {
const bt = fileStat.birthtime.getTime();
createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString();
}
updatedAt = fileStat.mtime.toISOString();
} catch {
/* leave undefined */
@ -354,12 +340,7 @@ export class TeamTaskReader {
continue;
}
const subject =
typeof parsed.subject === 'string'
? parsed.subject
: typeof parsed.title === 'string'
? parsed.title
: '';
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
const task: TeamTask = {
id:

View file

@ -6,7 +6,6 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher';
export { MemberStatsComputer } from './MemberStatsComputer';
export { ReviewApplierService } from './ReviewApplierService';
export { TaskBoundaryParser } from './TaskBoundaryParser';
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService';

View file

@ -16,7 +16,7 @@ import {
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { type Diagnostic, linter, lintGutter } from '@codemirror/lint';
import { lintGutter } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
@ -29,7 +29,7 @@ import {
} from '@codemirror/view';
import { api } from '@renderer/api';
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 type { AppConfig } from '@renderer/types/data';
@ -40,31 +40,6 @@ import type { AppConfig } from '@renderer/types/data';
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
// =============================================================================

View file

@ -3,10 +3,23 @@ import React, { useEffect, useRef } from 'react';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
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 { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import {
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
interface MembersJsonEditorProps {
value: string;
@ -14,6 +27,16 @@ interface MembersJsonEditorProps {
error: string | null;
}
const membersEditorTheme = EditorView.theme({
'&': {
fontSize: '12px',
maxHeight: '300px',
},
'.cm-scroller': {
overflow: 'auto',
},
});
export const MembersJsonEditor = ({
value,
onChange,
@ -31,30 +54,25 @@ export const MembersJsonEditor = ({
doc: value,
extensions: [
json(),
oneDark,
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
history(),
foldGutter(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(oneDarkHighlightStyle),
jsonLinter,
lintGutter(),
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
baseEditorTheme,
membersEditorTheme,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
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',
},
}),
],
});

View file

@ -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.
*/
import { type Diagnostic, linter } from '@codemirror/lint';
import { EditorView } from '@codemirror/view';
/** Base editor theme — general styling without diff-specific rules */
@ -50,4 +51,88 @@ export const baseEditorTheme = EditorView.theme({
'.cm-selectionBackground': {
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;
});

View file

@ -129,7 +129,7 @@ export interface TaskBoundary {
event: 'start' | 'complete';
lineNumber: number;
timestamp: string;
mechanism: 'TaskUpdate' | 'teamctl' | 'mcp';
mechanism: 'TaskUpdate' | 'mcp';
toolUseId?: string;
}
@ -158,7 +158,7 @@ export interface TaskBoundariesResult {
boundaries: TaskBoundary[];
scopes: TaskChangeScope[];
isSingleTaskSession: boolean;
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
detectedMechanism: 'TaskUpdate' | 'mcp' | 'none';
}
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */

View file

@ -64,55 +64,7 @@ describe('TaskBoundaryParser', () => {
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
});
it('falls back to legacy teamctl bash parsing for historical logs', 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 () => {
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, 'mixed.jsonl');
await fs.writeFile(
@ -155,5 +107,7 @@ describe('TaskBoundaryParser', () => {
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.detectedMechanism).toBe('mcp');
expect(result.boundaries).toHaveLength(1);
expect(result.boundaries[0]?.mechanism).toBe('mcp');
});
});

View file

@ -589,7 +589,7 @@ describe('TeamMemberLogsFinder', () => {
).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-'));
const structuredPath = path.join(tmpDir, 'structured.jsonl');
@ -646,7 +646,7 @@ describe('TeamMemberLogsFinder', () => {
const finder = new TeamMemberLogsFinder();
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);
});
});

File diff suppressed because it is too large Load diff