feat: enhance team management with improved session and project path history handling

- Introduced constants for maximum session and project path history limits to optimize memory usage.
- Updated `TeamConfigReader` and `TeamProvisioningService` to limit session and project path history to defined maximums.
- Enhanced `TeamMembersMetaStore` to handle large meta files more efficiently by checking file size before processing.
- Refactored state management in `teamSlice` to include optimized lookups for team summaries by name and session ID.
This commit is contained in:
iliya 2026-02-28 21:40:47 +02:00
parent 3b2a0de140
commit b2e48c6966
6 changed files with 55 additions and 31 deletions

View file

@ -12,6 +12,8 @@ const logger = createLogger('Service:TeamConfigReader');
const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12; const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
const LARGE_CONFIG_BYTES = 512 * 1024; const LARGE_CONFIG_BYTES = 512 * 1024;
const CONFIG_HEAD_BYTES = 64 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024;
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
async function mapLimit<T, R>( async function mapLimit<T, R>(
items: readonly T[], items: readonly T[],
@ -131,10 +133,10 @@ export class TeamConfigReader {
? config.leadSessionId ? config.leadSessionId
: undefined; : undefined;
projectPathHistory = Array.isArray(config.projectPathHistory) projectPathHistory = Array.isArray(config.projectPathHistory)
? config.projectPathHistory ? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY)
: undefined; : undefined;
sessionHistory = Array.isArray(config.sessionHistory) sessionHistory = Array.isArray(config.sessionHistory)
? config.sessionHistory ? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY)
: undefined; : undefined;
deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined;
} }
@ -165,24 +167,6 @@ export class TeamConfigReader {
} }
} }
const removedNames = new Set<string>();
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) {
if (member.removedAt) {
removedNames.add(member.name.trim());
} else {
addMember(member);
}
}
} 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 members = Array.from(memberMap.values());
const summary: TeamSummary = { const summary: TeamSummary = {
teamName, teamName,

View file

@ -11,6 +11,8 @@ interface TeamMembersMetaFile {
members: TeamMember[]; members: TeamMember[];
} }
const MAX_META_FILE_BYTES = 256 * 1024;
function normalizeMember(member: TeamMember): TeamMember | null { function normalizeMember(member: TeamMember): TeamMember | null {
const trimmedName = member.name?.trim(); const trimmedName = member.name?.trim();
if (!trimmedName) { if (!trimmedName) {
@ -35,6 +37,14 @@ export class TeamMembersMetaStore {
async getMembers(teamName: string): Promise<TeamMember[]> { async getMembers(teamName: string): Promise<TeamMember[]> {
const metaPath = this.getMetaPath(teamName); const metaPath = this.getMetaPath(teamName);
try {
const stat = await fs.promises.stat(metaPath);
if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) {
return [];
}
} catch {
// ignore - readFile below will handle ENOENT and throw on other errors
}
let raw: string; let raw: string;
try { try {
raw = await fs.promises.readFile(metaPath, 'utf8'); raw = await fs.promises.readFile(metaPath, 'utf8');

View file

@ -2620,6 +2620,8 @@ export class TeamProvisioningService {
projectPath: string, projectPath: string,
detectedSessionId: string | null detectedSessionId: string | null
): Promise<void> { ): Promise<void> {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try { try {
const raw = await fs.promises.readFile(configPath, 'utf8'); const raw = await fs.promises.readFile(configPath, 'utf8');
@ -2657,7 +2659,11 @@ export class TeamProvisioningService {
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`); logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
} }
config.sessionHistory = sessionHistory; if (sessionHistory.length > MAX_SESSION_HISTORY) {
config.sessionHistory = sessionHistory.slice(-MAX_SESSION_HISTORY);
} else {
config.sessionHistory = sessionHistory;
}
// Save current language setting // Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system'; const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
@ -2672,7 +2678,10 @@ export class TeamProvisioningService {
) )
: []; : [];
pathHistory.push(projectPath); pathHistory.push(projectPath);
config.projectPathHistory = pathHistory; config.projectPathHistory =
pathHistory.length > MAX_PROJECT_PATH_HISTORY
? pathHistory.slice(-MAX_PROJECT_PATH_HISTORY)
: pathHistory;
} }
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));

View file

@ -64,7 +64,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered, syncSearchMatchesWithRendered,
selectSearchMatch, selectSearchMatch,
setTabVisibleAIGroup, setTabVisibleAIGroup,
teams,
openTeamTab, openTeamTab,
openSessionReport, openSessionReport,
} = useStore( } = useStore(
@ -79,7 +78,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered, syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered,
selectSearchMatch: s.selectSearchMatch, selectSearchMatch: s.selectSearchMatch,
setTabVisibleAIGroup: s.setTabVisibleAIGroup, setTabVisibleAIGroup: s.setTabVisibleAIGroup,
teams: s.teams,
openTeamTab: s.openTeamTab, openTeamTab: s.openTeamTab,
openSessionReport: s.openSessionReport, openSessionReport: s.openSessionReport,
})) }))
@ -126,12 +124,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
const pendingNavigation = thisTab?.pendingNavigation; const pendingNavigation = thisTab?.pendingNavigation;
const teamBySessionId = useStore((s) => s.teamBySessionId);
// Look up whether this session belongs to a team // Look up whether this session belongs to a team
const sessionTeam = useMemo(() => { const sessionTeam = useMemo(() => {
if (!sessionDetail?.session?.id) return null; const sid = sessionDetail?.session?.id;
const sid = sessionDetail.session.id; if (!sid) return null;
return teams.find((t) => t.leadSessionId === sid || t.sessionHistory?.includes(sid)) ?? null; return teamBySessionId[sid] ?? null;
}, [teams, sessionDetail?.session?.id]); }, [teamBySessionId, sessionDetail?.session?.id]);
// Compute all accumulated context injections (phase-aware) // Compute all accumulated context injections (phase-aware)
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {

View file

@ -64,7 +64,7 @@ export const SidebarTaskItem = ({
showTeamName, showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => { }: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members); const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg = const cfg =
task.kanbanColumn === 'approved' task.kanbanColumn === 'approved'

View file

@ -92,6 +92,10 @@ export interface GlobalTaskDetailState {
export interface TeamSlice { export interface TeamSlice {
teams: TeamSummary[]; teams: TeamSummary[];
/** O(1) lookup to avoid array scans in render-hot paths */
teamByName: Record<string, TeamSummary>;
/** O(1) lookup: sessionId -> owning team (lead + history) */
teamBySessionId: Record<string, TeamSummary>;
teamsLoading: boolean; teamsLoading: boolean;
teamsError: string | null; teamsError: string | null;
globalTasks: GlobalTask[]; globalTasks: GlobalTask[];
@ -175,6 +179,8 @@ export interface TeamSlice {
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [], teams: [],
teamByName: {},
teamBySessionId: {},
teamsLoading: false, teamsLoading: false,
teamsError: null, teamsError: null,
globalTasks: [], globalTasks: [],
@ -223,7 +229,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
try { try {
const teams = await unwrapIpc('team:list', () => api.teams.list()); const teams = await unwrapIpc('team:list', () => api.teams.list());
set({ teams, teamsLoading: false, teamsError: null }); const teamByName: Record<string, TeamSummary> = {};
const teamBySessionId: Record<string, TeamSummary> = {};
for (const team of teams) {
teamByName[team.teamName] = team;
if (team.leadSessionId) {
teamBySessionId[team.leadSessionId] = team;
}
if (Array.isArray(team.sessionHistory)) {
for (const sid of team.sessionHistory) {
if (typeof sid === 'string' && sid) {
teamBySessionId[sid] = team;
}
}
}
}
set({ teams, teamByName, teamBySessionId, teamsLoading: false, teamsError: null });
} catch (error) { } catch (error) {
// On refresh failure, keep existing teams visible // On refresh failure, keep existing teams visible
set({ set({
@ -313,7 +334,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const state = get(); const state = get();
// Use display name from teams list or selected team data if available // Use display name from teams list or selected team data if available
const teamSummary = state.teams.find((t) => t.teamName === teamName); const teamSummary = state.teamByName[teamName];
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName; const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
const allTabs = state.getAllPaneTabs(); const allTabs = state.getAllPaneTabs();