feat: enhance TeamConfigReader with improved file handling and concurrency
- Introduced `mapLimit` function to manage concurrent processing of team directories, optimizing performance. - Added `readFileHead` function to read the beginning of large configuration files efficiently. - Implemented `extractQuotedString` to safely extract values from JSON strings in configuration headers. - Enhanced error handling and validation for team configuration files, ensuring robust processing of team data. - Updated logic to handle large configuration files differently, improving overall reliability and performance.
This commit is contained in:
parent
0cb85d463c
commit
3b2a0de140
3 changed files with 188 additions and 102 deletions
|
|
@ -9,6 +9,55 @@ import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@sh
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamConfigReader');
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export class TeamConfigReader {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||||
|
|
@ -24,111 +73,141 @@ export class TeamConfigReader {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaries: TeamSummary[] = [];
|
const teamDirs = entries.filter((e) => e.isDirectory());
|
||||||
|
|
||||||
for (const entry of entries) {
|
const perTeam: (TeamSummary | null)[] = await mapLimit(
|
||||||
if (!entry.isDirectory()) {
|
teamDirs,
|
||||||
continue;
|
TEAM_LIST_CONCURRENCY,
|
||||||
}
|
async (entry): Promise<TeamSummary | null> => {
|
||||||
|
const teamName = entry.name;
|
||||||
|
const configPath = path.join(teamsDir, teamName, 'config.json');
|
||||||
|
|
||||||
const configPath = path.join(teamsDir, entry.name, 'config.json');
|
|
||||||
try {
|
|
||||||
const raw = await fs.promises.readFile(configPath, 'utf8');
|
|
||||||
const config = JSON.parse(raw) as TeamConfig;
|
|
||||||
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
|
||||||
logger.debug(`Skipping team dir with invalid config name: ${entry.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberMap = new Map<string, TeamSummaryMember>();
|
|
||||||
|
|
||||||
const addMember = (m: TeamMember): void => {
|
|
||||||
const name = m.name?.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const existing = memberMap.get(name);
|
|
||||||
memberMap.set(name, {
|
|
||||||
name,
|
|
||||||
role: m.role?.trim() || existing?.role,
|
|
||||||
color: m.color?.trim() || existing?.color,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(config.members)) {
|
|
||||||
for (const member of config.members) {
|
|
||||||
if (member && typeof member.name === 'string') {
|
|
||||||
addMember(member);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedNames = new Set<string>();
|
|
||||||
try {
|
try {
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
|
let config: TeamConfig | null = null;
|
||||||
for (const member of metaMembers) {
|
let displayName: string | null = null;
|
||||||
if (member.removedAt) {
|
let description = '';
|
||||||
removedNames.add(member.name.trim());
|
let color: string | undefined;
|
||||||
} else {
|
let projectPath: string | undefined;
|
||||||
addMember(member);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat && stat.isFile() && stat.size > LARGE_CONFIG_BYTES) {
|
||||||
|
const head = await readFileHead(configPath, CONFIG_HEAD_BYTES);
|
||||||
|
displayName = extractQuotedString(head, 'name');
|
||||||
|
const desc = extractQuotedString(head, 'description');
|
||||||
|
description = typeof desc === 'string' ? desc : '';
|
||||||
|
const c = extractQuotedString(head, 'color');
|
||||||
|
color = typeof c === 'string' && c.trim().length > 0 ? c : undefined;
|
||||||
|
const pp = extractQuotedString(head, 'projectPath');
|
||||||
|
projectPath = typeof pp === 'string' && pp.trim().length > 0 ? pp : undefined;
|
||||||
|
const lead = extractQuotedString(head, 'leadSessionId');
|
||||||
|
leadSessionId = typeof lead === 'string' && lead.trim().length > 0 ? lead : undefined;
|
||||||
|
const del = extractQuotedString(head, 'deletedAt');
|
||||||
|
deletedAt = typeof del === 'string' ? del : undefined;
|
||||||
|
} else {
|
||||||
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
||||||
|
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
|
||||||
|
: undefined;
|
||||||
|
sessionHistory = Array.isArray(config.sessionHistory)
|
||||||
|
? config.sessionHistory
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberMap = new Map<string, TeamSummaryMember>();
|
||||||
|
|
||||||
|
const addMember = (m: TeamMember): void => {
|
||||||
|
const name = m.name?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const existing = memberMap.get(name);
|
||||||
|
memberMap.set(name, {
|
||||||
|
name,
|
||||||
|
role: m.role?.trim() || existing?.role,
|
||||||
|
color: m.color?.trim() || existing?.color,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config && Array.isArray(config.members)) {
|
||||||
|
for (const member of config.members) {
|
||||||
|
if (member && typeof member.name === 'string') {
|
||||||
|
addMember(member);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inboxDir = path.join(teamsDir, entry.name, 'inboxes');
|
const removedNames = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const inboxEntries = await fs.promises.readdir(inboxDir);
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
for (const inbox of inboxEntries) {
|
for (const member of metaMembers) {
|
||||||
if (!inbox.endsWith('.json') || inbox.startsWith('.')) {
|
if (member.removedAt) {
|
||||||
continue;
|
removedNames.add(member.name.trim());
|
||||||
}
|
} else {
|
||||||
const inboxName = inbox.slice(0, -'.json'.length).trim();
|
addMember(member);
|
||||||
if (inboxName.length > 0 && !memberMap.has(inboxName)) {
|
}
|
||||||
memberMap.set(inboxName, { name: inboxName });
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
logger.debug(`Failed to read members.meta.json for team: ${teamName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const name of removedNames) {
|
||||||
|
memberMap.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} catch {
|
||||||
// Inbox folder may not exist yet.
|
logger.debug(`Skipping team dir without valid config: ${teamName}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of removedNames) {
|
|
||||||
memberMap.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberCount = memberMap.size;
|
|
||||||
const members = Array.from(memberMap.values());
|
|
||||||
summaries.push({
|
|
||||||
teamName: entry.name,
|
|
||||||
displayName: config.name,
|
|
||||||
description: typeof config.description === 'string' ? config.description : '',
|
|
||||||
color:
|
|
||||||
typeof config.color === 'string' && config.color.trim().length > 0
|
|
||||||
? config.color
|
|
||||||
: undefined,
|
|
||||||
memberCount,
|
|
||||||
members: members.length > 0 ? members : undefined,
|
|
||||||
taskCount: 0,
|
|
||||||
lastActivity: null,
|
|
||||||
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
|
|
||||||
: undefined,
|
|
||||||
sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined,
|
|
||||||
deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
logger.debug(`Skipping team dir without valid config: ${entry.name}`);
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
return summaries;
|
return perTeam.filter((t): t is TeamSummary => t !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
async getConfig(teamName: string): Promise<TeamConfig | null> {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { initializeNotificationListeners, useStore } from './store';
|
import { useStore } from './store';
|
||||||
|
|
||||||
export const App = (): React.JSX.Element => {
|
export const App = (): React.JSX.Element => {
|
||||||
// Initialize theme on app load
|
// Initialize theme on app load
|
||||||
|
|
@ -23,14 +23,6 @@ export const App = (): React.JSX.Element => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize IPC listeners and start sequential data fetch chain.
|
|
||||||
// No delay needed: UV_THREADPOOL_SIZE=16 prevents thread pool saturation,
|
|
||||||
// and the init chain fetches data sequentially to avoid concurrent I/O spikes.
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = initializeNotificationListeners();
|
|
||||||
return cleanup;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initialize context system lazily when SSH connection state changes.
|
// Initialize context system lazily when SSH connection state changes.
|
||||||
// Local-only users never pay the cost of IndexedDB init + context IPC calls.
|
// Local-only users never pay the cost of IndexedDB init + context IPC calls.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@ import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { initializeNotificationListeners } from './store';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__claudeTeamsUiDidInit?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
||||||
|
// which can start duplicate IPC init chains. Make initialization a one-time
|
||||||
|
// module-level side effect guarded by a global flag.
|
||||||
|
if (!window.__claudeTeamsUiDidInit) {
|
||||||
|
window.__claudeTeamsUiDidInit = true;
|
||||||
|
initializeNotificationListeners();
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue