agent-ecosystem/src/main/services/team/TeamConfigReader.ts
iliya 032d9b478b feat: enhance team member validation and improve logging
- Added validation to prevent the use of reserved names ("user" and "team-lead") for team members, ensuring clearer error messages during member addition.
- Updated IPC handlers to improve logging functionality, enhancing observability of team-related actions.
- Implemented normalization of file paths across various services to ensure consistent handling on different platforms.
- Enhanced UI components to provide better feedback on team member statuses and actions.

Made-with: Cursor
2026-03-03 23:58:19 +02:00

350 lines
13 KiB
TypeScript

import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
const logger = createLogger('Service:TeamConfigReader');
const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
const LARGE_CONFIG_BYTES = 512 * 1024;
const CONFIG_HEAD_BYTES = 64 * 1024;
const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let index = 0;
const workerCount = Math.max(1, Math.min(limit, items.length));
const workers = new Array(workerCount).fill(0).map(async () => {
while (true) {
const i = index++;
if (i >= items.length) return;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
function withReadTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('Team config read timeout')), ms);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function readFileHead(filePath: string, maxBytes: number): Promise<string> {
const handle = await fs.promises.open(filePath, 'r');
try {
const stat = await handle.stat();
const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes));
if (bytesToRead === 0) return '';
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, 0);
return buffer.toString('utf8');
} finally {
await handle.close();
}
}
function extractQuotedString(head: string, key: string): string | null {
const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`);
const match = re.exec(head);
if (!match?.[1]) return null;
try {
const value = JSON.parse(match[1]) as unknown;
return typeof value === 'string' ? value : null;
} catch {
return null;
}
}
export class TeamConfigReader {
constructor(
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
async listTeams(): Promise<TeamSummary[]> {
const worker = getTeamFsWorkerClient();
if (worker.isAvailable()) {
const startedAt = Date.now();
try {
const { teams, diag } = await worker.listTeams({
largeConfigBytes: LARGE_CONFIG_BYTES,
configHeadBytes: CONFIG_HEAD_BYTES,
maxConfigBytes: MAX_CONFIG_READ_BYTES,
maxMembersMetaBytes: 256 * 1024,
maxSessionHistoryInSummary: MAX_SESSION_HISTORY_IN_SUMMARY,
maxProjectPathHistoryInSummary: MAX_PROJECT_PATH_HISTORY_IN_SUMMARY,
concurrency: TEAM_LIST_CONCURRENCY,
maxConfigReadMs: PER_TEAM_READ_TIMEOUT_MS,
});
const ms = Date.now() - startedAt;
const skipReasons =
diag && typeof diag === 'object' ? (diag as Record<string, unknown>).skipReasons : null;
if (skipReasons && typeof skipReasons === 'object') {
const bad =
Number((skipReasons as Record<string, unknown>).config_parse_failed ?? 0) +
Number((skipReasons as Record<string, unknown>).config_read_timeout ?? 0);
if (bad > 0) {
logger.warn(`[listTeams] worker skipped broken team configs count=${bad}`);
}
}
if (ms >= 1500) {
logger.warn(`[listTeams] worker slow ms=${ms} diag=${JSON.stringify(diag)}`);
}
return teams;
} catch (error) {
logger.warn(
`[listTeams] worker failed: ${error instanceof Error ? error.message : String(error)}`
);
// Fall through to in-process implementation.
}
}
const teamsDir = getTeamsBasePath();
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(teamsDir, { withFileTypes: true });
} catch {
return [];
}
const teamDirs = entries.filter((e) => e.isDirectory());
const perTeam: (TeamSummary | null)[] = await mapLimit(
teamDirs,
TEAM_LIST_CONCURRENCY,
async (entry): Promise<TeamSummary | null> => {
const teamName = entry.name;
try {
return await withReadTimeout(
this.readTeamSummary(teamsDir, teamName),
PER_TEAM_READ_TIMEOUT_MS
);
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown';
logger.warn(`Skipping team dir (${reason}): ${teamName}`);
return null;
}
}
);
return perTeam.filter((t): t is TeamSummary => t !== null);
}
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
const configPath = path.join(teamsDir, teamName, 'config.json');
try {
let config: TeamConfig | null = null;
let displayName: string | null = null;
let description = '';
let color: string | undefined;
let projectPath: string | undefined;
let leadSessionId: string | undefined;
let deletedAt: string | undefined;
let projectPathHistory: TeamConfig['projectPathHistory'] | undefined;
let sessionHistory: TeamConfig['sessionHistory'] | undefined;
let stat: fs.Stats | null = null;
try {
stat = await fs.promises.stat(configPath);
} catch {
stat = null;
}
// Skip non-regular files (pipes, sockets, etc.) — readFile could hang on them
if (!stat?.isFile()) {
logger.debug(`Skipping team dir with missing/non-file config: ${teamName}`);
return null;
}
// Safety: refuse to touch extremely large configs. Even "head" parsing can be misleading,
// and full reads/parses can stall the main process.
if (stat.size > MAX_CONFIG_READ_BYTES) {
logger.warn(
`Skipping team dir with oversized config.json (${stat.size} bytes): ${teamName}`
);
return null;
}
if (stat.size > LARGE_CONFIG_BYTES) {
// Defensive: avoid any reads from very large configs during listing.
// If the team is real, it can still be opened later via getConfig().
displayName = teamName;
} else {
const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS);
config = JSON.parse(raw) as TeamConfig;
displayName = typeof config.name === 'string' ? config.name : null;
description = typeof config.description === 'string' ? config.description : '';
color =
typeof config.color === 'string' && config.color.trim().length > 0
? config.color
: undefined;
projectPath =
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId
: undefined;
projectPathHistory = Array.isArray(config.projectPathHistory)
? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY)
: undefined;
sessionHistory = Array.isArray(config.sessionHistory)
? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY)
: undefined;
deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined;
}
if (typeof displayName !== 'string' || displayName.trim() === '') {
logger.debug(`Skipping team dir with invalid config name: ${teamName}`);
return null;
}
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
const memberMap = new Map<string, TeamSummaryMember>();
const removedKeys = new Set<string>();
const mergeMember = (m: TeamMember): void => {
const name = m.name?.trim();
if (!name) return;
// Summary/memberCount should represent teammates (exclude the lead process).
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
const key = name.toLowerCase();
// If meta marks this name removed, do not surface it in summaries
if (removedKeys.has(key)) return;
const existing = memberMap.get(key);
memberMap.set(key, {
name: existing?.name ?? name,
role: m.role?.trim() || existing?.role,
color: m.color?.trim() || existing?.color,
});
};
// Also read members.meta.json — UI-created teams store members there,
// and CLI-created teams may have additional members added via the UI.
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) {
const name = member.name?.trim();
if (!name) continue;
// Summary/memberCount should represent teammates (exclude the lead process).
if (name === 'team-lead' || name === 'user' || member.agentType === 'team-lead') continue;
const key = name.toLowerCase();
if (member.removedAt) {
removedKeys.add(key);
continue;
}
mergeMember(member);
}
} catch {
// best-effort — don't fail listing if meta file is broken
}
// Merge config members AFTER meta so removedAt can suppress stale config entries.
if (config && Array.isArray(config.members)) {
for (const member of config.members) {
if (member && typeof member.name === 'string') {
mergeMember(member);
}
}
}
const members = Array.from(memberMap.values());
const summary: TeamSummary = {
teamName,
displayName,
description,
memberCount: memberMap.size,
taskCount: 0,
lastActivity: null,
...(members.length > 0 ? { members } : {}),
...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}),
...(projectPathHistory ? { projectPathHistory } : {}),
...(sessionHistory ? { sessionHistory } : {}),
...(deletedAt ? { deletedAt } : {}),
};
return summary;
} catch {
logger.debug(`Skipping team dir without valid config: ${teamName}`);
return null;
}
}
async getConfig(teamName: string): Promise<TeamConfig | null> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const stat = await fs.promises.stat(configPath);
// Safety: refuse special files and huge/binary configs
if (!stat.isFile()) {
return null;
}
if (stat.size > MAX_CONFIG_READ_BYTES) {
logger.warn(
`Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}`
);
return null;
}
const raw = await readFileUtf8WithTimeout(configPath, PER_TEAM_READ_TIMEOUT_MS);
const config = JSON.parse(raw) as TeamConfig;
if (typeof config.name !== 'string' || config.name.trim() === '') {
return null;
}
return config;
} catch (error) {
if (error instanceof FileReadTimeoutError) {
logger.warn(`[getConfig] ${error.message}`);
return null;
}
return null;
}
}
async updateConfig(
teamName: string,
updates: { name?: string; description?: string; color?: string; language?: string }
): Promise<TeamConfig | null> {
const config = await this.getConfig(teamName);
if (!config) {
return null;
}
if (updates.name !== undefined && updates.name.trim() !== '') {
config.name = updates.name.trim();
}
if (updates.description !== undefined) {
config.description = updates.description.trim() || undefined;
}
if (updates.color !== undefined) {
config.color = updates.color.trim() || undefined;
}
if (updates.language !== undefined) {
config.language = updates.language.trim() || undefined;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
return config;
}
}