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 LARGE_CONFIG_BYTES = 512 * 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>(
items: readonly T[],
@ -131,10 +133,10 @@ export class TeamConfigReader {
? config.leadSessionId
: undefined;
projectPathHistory = Array.isArray(config.projectPathHistory)
? config.projectPathHistory
? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY)
: undefined;
sessionHistory = Array.isArray(config.sessionHistory)
? config.sessionHistory
? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY)
: 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 summary: TeamSummary = {
teamName,

View file

@ -11,6 +11,8 @@ interface TeamMembersMetaFile {
members: TeamMember[];
}
const MAX_META_FILE_BYTES = 256 * 1024;
function normalizeMember(member: TeamMember): TeamMember | null {
const trimmedName = member.name?.trim();
if (!trimmedName) {
@ -35,6 +37,14 @@ export class TeamMembersMetaStore {
async getMembers(teamName: string): Promise<TeamMember[]> {
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;
try {
raw = await fs.promises.readFile(metaPath, 'utf8');

View file

@ -2620,6 +2620,8 @@ export class TeamProvisioningService {
projectPath: string,
detectedSessionId: string | null
): Promise<void> {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
@ -2657,7 +2659,11 @@ export class TeamProvisioningService {
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
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
@ -2672,7 +2678,10 @@ export class TeamProvisioningService {
)
: [];
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));

View file

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

View file

@ -64,7 +64,7 @@ export const SidebarTaskItem = ({
showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => {
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 cfg =
task.kanbanColumn === 'approved'

View file

@ -92,6 +92,10 @@ export interface GlobalTaskDetailState {
export interface TeamSlice {
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;
teamsError: string | null;
globalTasks: GlobalTask[];
@ -175,6 +179,8 @@ export interface TeamSlice {
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [],
teamByName: {},
teamBySessionId: {},
teamsLoading: false,
teamsError: null,
globalTasks: [],
@ -223,7 +229,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
try {
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) {
// On refresh failure, keep existing teams visible
set({
@ -313,7 +334,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const state = get();
// 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 allTabs = state.getAllPaneTabs();