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:
parent
3b2a0de140
commit
b2e48c6966
6 changed files with 55 additions and 31 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue