From 2cfbfef3b320766f25e4c55ee69a5560502fa839 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 17:38:21 +0300 Subject: [PATCH] fix(team): recover root member session logs --- src/main/services/team/TeamConfigReader.ts | 53 +- .../services/team/TeamMemberLogsFinder.ts | 652 ++++++++++++------ .../team/TeamTranscriptProjectResolver.ts | 297 ++++++++ src/main/services/team/TeammateToolTracker.ts | 2 +- .../discovery/TeamTranscriptSourceLocator.ts | 111 +-- .../components/team/members/MemberLogsTab.tsx | 31 +- src/shared/types/team.ts | 11 +- .../team/TeamMemberLogsFinder.test.ts | 114 ++- .../team/TeamTranscriptSourceLocator.test.ts | 114 +++ 9 files changed, 1066 insertions(+), 319 deletions(-) create mode 100644 src/main/services/team/TeamTranscriptProjectResolver.ts create mode 100644 test/main/services/team/TeamTranscriptSourceLocator.test.ts diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 7143cf53..f8c4fd49 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -32,6 +32,51 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +function normalizeProjectPathCandidate(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveProjectPathFromConfig( + config: Pick +): string | undefined { + const direct = normalizeProjectPathCandidate(config.projectPath); + if (direct) { + return direct; + } + + const leadMemberCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + const leadResolved = normalizeProjectPathCandidate(leadMemberCwd); + if (leadResolved) { + return leadResolved; + } + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + return distinctMemberCwds[0]; + } + + if (Array.isArray(config.projectPathHistory)) { + for (let i = config.projectPathHistory.length - 1; i >= 0; i--) { + const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]); + if (historyValue) { + return historyValue; + } + } + } + + return undefined; +} + interface LaunchStateSummary { partialLaunchFailure?: true; expectedMemberCount?: number; @@ -250,10 +295,7 @@ export class TeamConfigReader { 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; + projectPath = resolveProjectPathFromConfig(config); leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId @@ -470,7 +512,8 @@ export class TeamConfigReader { if (typeof config.name !== 'string' || config.name.trim() === '') { return null; } - return config; + const resolvedProjectPath = resolveProjectPathFromConfig(config); + return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config; } catch (error) { if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message}`); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index c25220ac..b2dfdbe9 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1,4 +1,3 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; @@ -14,8 +13,13 @@ import { import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; -import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; +import type { + MemberLogSummary, + MemberSessionLogSummary, + MemberSubagentLogSummary, +} from '@shared/types'; const logger = createLogger('Service:TeamMemberLogsFinder'); @@ -76,23 +80,32 @@ interface SubagentAttribution { firstTimestamp: string | null; } -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - // '/' or '\' - if (ch === 47 || ch === 92) { - end--; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); +interface RootSessionAttribution { + detectedMember: string; + description: string; + firstTimestamp: string | null; } +type LogCandidate = + | { + kind: 'subagent'; + filePath: string; + sessionId: string; + fileName: string; + } + | { + kind: 'member_session'; + filePath: string; + sessionId: string; + fileName: string; + }; + export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); - private readonly attributionCache = new Map(); + private readonly attributionCache = new Map< + string, + SubagentAttribution | RootSessionAttribution | null + >(); private readonly discoveryCache = new Map< string, { @@ -104,7 +117,10 @@ export class TeamMemberLogsFinder { constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), - private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) ) {} async findMemberLogs( @@ -134,8 +150,8 @@ export class TeamMemberLogsFinder { } // ── Collect and parallel-scan subagent files ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); - const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); let nextIdx = 0; const scanWorker = async (): Promise => { @@ -152,17 +168,27 @@ export class TeamMemberLogsFinder { continue; } } - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - memberName, - knownMembers - ); + const summary = + c.kind === 'subagent' + ? await this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + memberName, + knownMembers + ) + : await this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + memberName, + teamName, + knownMembers + ); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to parse subagent summary: ${c.filePath}`, err); + logger.warn(`Failed to parse member log summary: ${c.filePath}`, err); } } }; @@ -192,7 +218,7 @@ export class TeamMemberLogsFinder { this.discoveryCache.delete(teamName); } - const discovery = await this.discoverProjectSessions(teamName); + const discovery = await this.discoverProjectSessions(teamName, options); if (!discovery) { return null; } @@ -258,7 +284,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); @@ -273,20 +299,41 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); - if (!attribution) continue; - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - attribution.detectedMember, - knownMembers, - attribution - ); + const summary = + c.kind === 'subagent' + ? await (async (): Promise => { + const attribution = await this.attributeSubagent(c.filePath, knownMembers); + if (!attribution) return null; + return this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + attribution.detectedMember, + knownMembers, + attribution + ); + })() + : await (async (): Promise => { + const attribution = await this.attributeMemberSession( + c.filePath, + teamName, + knownMembers + ); + if (!attribution) return null; + return this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + attribution.detectedMember, + teamName, + knownMembers, + attribution + ); + })(); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -368,14 +415,18 @@ export class TeamMemberLogsFinder { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; seen.add(key); } for (const log of filteredOwnerLogs) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; if (!seen.has(key)) { seen.add(key); results.push(log); @@ -455,16 +506,28 @@ export class TeamMemberLogsFinder { const sinceMs = this.deriveSinceMs(options); const { projectDir, config, sessionIds, knownMembers } = discovery; - const refs: { filePath: string; memberName: string; sortTime: number }[] = []; + const refs: { + kind: LogCandidate['kind'] | 'lead_session'; + filePath: string; + memberName: string; + sessionId: string; + sortTime: number; + }[] = []; const seen = new Set(); const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; - const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { - const key = `${memberName.toLowerCase()}:${filePath}`; + const pushRef = ( + filePath: string, + memberName: string, + sortTime = 0, + kind: LogCandidate['kind'] | 'lead_session' = 'member_session', + sessionId = '' + ): void => { + const key = `${kind}:${sessionId}:${memberName.toLowerCase()}:${filePath}`; if (seen.has(key)) return; seen.add(key); - refs.push({ filePath, memberName, sortTime }); + refs.push({ kind, filePath, memberName, sessionId, sortTime }); }; if (config.leadSessionId) { @@ -473,7 +536,13 @@ export class TeamMemberLogsFinder { await fs.access(leadJsonl); if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); - pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp)); + pushRef( + leadJsonl, + leadMemberName, + await this.getSortTime(leadJsonl, firstTimestamp), + 'lead_session', + config.leadSessionId + ); } } catch { // file missing or unreadable @@ -482,7 +551,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── let nextIdx = 0; @@ -496,15 +565,20 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); + const attribution = + c.kind === 'subagent' + ? await this.attributeSubagent(c.filePath, knownMembers) + : await this.attributeMemberSession(c.filePath, teamName, knownMembers); if (!attribution) continue; pushRef( c.filePath, attribution.detectedMember, - await this.getSortTime(c.filePath, attribution.firstTimestamp) + await this.getSortTime(c.filePath, attribution.firstTimestamp), + c.kind, + c.sessionId ); } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -585,7 +659,11 @@ export class TeamMemberLogsFinder { pushRef( log.filePath, log.memberName ?? normalizedOwner, - Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0 + Number.isFinite(new Date(log.startTime).getTime()) + ? new Date(log.startTime).getTime() + : 0, + log.kind === 'lead_session' ? 'lead_session' : log.kind, + log.sessionId ); } } @@ -595,22 +673,28 @@ export class TeamMemberLogsFinder { { const refsByKey = new Map(); const leadRefs: (typeof refs)[0][] = []; + const memberSessionRefsByKey = new Map(); for (const ref of refs) { - if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) { + if (ref.kind === 'lead_session') { leadRefs.push(ref); continue; } - const parts = ref.filePath.split(path.sep); - const subagentsIdx = parts.lastIndexOf('subagents'); - const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : ''; - const key = `${sessionId}:${ref.memberName.toLowerCase()}`; + if (ref.kind === 'member_session') { + const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`; + const existing = memberSessionRefsByKey.get(key); + if (!existing || ref.sortTime > existing.sortTime) { + memberSessionRefsByKey.set(key, ref); + } + continue; + } + const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`; const existing = refsByKey.get(key); if (!existing || ref.sortTime > existing.sortTime) { refsByKey.set(key, ref); } } refs.length = 0; - refs.push(...leadRefs, ...refsByKey.values()); + refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values()); } const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); @@ -650,43 +734,32 @@ export class TeamMemberLogsFinder { } } - for (const sessionId of sessionIds) { - const subagentsDir = path.join(projectDir, sessionId, 'subagents'); - - let files: string[]; + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + for (const candidate of candidates) { + let mtimeMs = 0; try { - files = await fs.readdir(subagentsDir); + mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs; } catch { continue; } - - for (const file of files) { - if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; - if (file.startsWith('agent-acompact')) continue; - - const filePath = path.join(subagentsDir, file); - // Quick attribution check — only Phase 1 (no full-file streaming) - let mtimeMs = 0; - try { - mtimeMs = (await fs.stat(filePath)).mtimeMs; - } catch { - continue; - } - const attribution = await this.getCachedSubagentAttribution( - filePath, - knownMembers, - mtimeMs - ); - if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { - paths.push(filePath); - } + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + mtimeMs + ); + if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { + paths.push(candidate.filePath); } } return paths; } - async listAttributedSubagentFiles( + async listAttributedMemberFiles( teamName: string ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); @@ -697,13 +770,7 @@ export class TeamMemberLogsFinder { typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId.trim() : null; - // Live teammate tool tracking should follow the current team run, not historical - // lead sessions kept in sessionHistory or lingering on disk. - const candidateSessionIds = - currentLeadSessionId && sessionIds.includes(currentLeadSessionId) - ? [currentLeadSessionId] - : sessionIds; - const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const results: { memberName: string; sessionId: string; @@ -714,12 +781,27 @@ export class TeamMemberLogsFinder { const settled = await Promise.all( candidates.map(async (candidate) => { try { + if ( + candidate.kind === 'subagent' && + currentLeadSessionId && + candidate.sessionId !== currentLeadSessionId + ) { + return null; + } const stat = await fs.stat(candidate.filePath); - const attribution = await this.getCachedSubagentAttribution( - candidate.filePath, - knownMembers, - stat.mtimeMs - ); + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + stat.mtimeMs + ); if (!attribution) return null; return { memberName: attribution.detectedMember, @@ -737,7 +819,34 @@ export class TeamMemberLogsFinder { if (item) results.push(item); } - return results; + const latestRootSessionsByMember = new Map< + string, + { memberName: string; sessionId: string; filePath: string; mtimeMs: number } + >(); + const passthrough: typeof results = []; + + for (const item of results) { + if ( + !item.filePath.endsWith('.jsonl') || + item.filePath.includes(`${path.sep}subagents${path.sep}`) + ) { + passthrough.push(item); + continue; + } + const key = item.memberName.toLowerCase(); + const existing = latestRootSessionsByMember.get(key); + if (!existing || item.mtimeMs > existing.mtimeMs) { + latestRootSessionsByMember.set(key, item); + } + } + + return [...passthrough, ...latestRootSessionsByMember.values()]; + } + + async listAttributedSubagentFiles( + teamName: string + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { + return this.listAttributedMemberFiles(teamName); } /** @@ -783,97 +892,32 @@ export class TeamMemberLogsFinder { return false; } - private async discoverProjectSessions(teamName: string): Promise<{ + private async discoverProjectSessions( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; } | null> { - // Check discovery cache — avoids re-reading config/dirs within rapid successive calls - const cached = this.discoveryCache.get(teamName); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } else { + // Check discovery cache — avoids re-reading config/dirs within rapid successive calls + const cached = this.discoveryCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } } - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { - logger.debug(`No projectPath for team "${teamName}"`); + const context = await this.projectResolver.getContext(teamName, options); + if (!context) { + logger.debug(`No transcript context for team "${teamName}"`); return null; } - - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let baseDir = extractBaseDir(projectId); - let projectDir = path.join(getProjectsBasePath(), baseDir); - - // If the encoded directory doesn't exist (symlink/cwd mismatch), fall back to locating - // the project directory by leadSessionId which is unique and reliable. - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - const projectsBase = getProjectsBasePath(); - try { - const entries = await fs.readdir(projectsBase, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(projectsBase, entry.name); - const leadPath = path.join(candidateDir, `${leadSessionId}.jsonl`); - try { - await fs.access(leadPath); - projectDir = candidateDir; - projectId = entry.name; - baseDir = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // ignore - } - } - } - - const knownSessionIds = new Set(); - if (config.leadSessionId) { - knownSessionIds.add(config.leadSessionId); - } - if (Array.isArray(config.sessionHistory)) { - for (const sid of config.sessionHistory) { - if (typeof sid === 'string' && sid.trim().length > 0) { - knownSessionIds.add(sid.trim()); - } - } - } - - const discoveredSessionIds = await this.listSessionDirs(projectDir); - let sessionIds: string[]; - if (knownSessionIds.size > 0) { - const verified: string[] = []; - for (const sid of knownSessionIds) { - const sidDir = path.join(projectDir, sid); - try { - const stat = await fs.stat(sidDir); - if (stat.isDirectory()) verified.push(sid); - } catch { - // dir doesn't exist - } - } - // Prefer config-backed sessions first, but also include any live session dirs that have - // appeared on disk and are not yet reflected in config/sessionHistory. - sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds])); - } else { - sessionIds = discoveredSessionIds; - } + const { config, projectDir, projectId, sessionIds } = context; const knownMembers = new Set( (config.members ?? []) @@ -927,16 +971,42 @@ export class TeamMemberLogsFinder { return { ...discovery, isLeadMember }; } - /** - * Collect all subagent JSONL file candidates across session directories. - * Filters out non-agent files and compact files (agent-acompact*). - */ - private async collectSubagentCandidates( + private async collectLogCandidates( projectDir: string, - sessionIds: string[] - ): Promise<{ filePath: string; sessionId: string; fileName: string }[]> { - const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; + sessionIds: string[], + config: NonNullable>> + ): Promise { + const candidates: LogCandidate[] = []; + const leadSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + leadSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + leadSessionIds.add(sessionId.trim()); + } + } + } + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + if (!leadSessionIds.has(sessionId)) { + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + candidates.push({ + kind: 'member_session', + filePath: mainTranscript, + sessionId, + fileName: path.basename(mainTranscript), + }); + } + } catch { + // missing root transcript + } + } + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let dirFiles: string[]; try { @@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder { for (const f of dirFiles) { if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; - candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); + candidates.push({ + kind: 'subagent', + filePath: path.join(subagentsDir, f), + sessionId, + fileName: f, + }); } } return candidates; @@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder { return null; } - private async listSessionDirs(projectDir: string): Promise { - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch { - logger.debug(`Cannot read project dir: ${projectDir}`); - return []; - } - } - private async getCachedSubagentAttribution( filePath: string, knownMembers: Set, @@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder { return attribution; } + private async getCachedMemberSessionAttribution( + filePath: string, + teamName: string, + knownMembers: Set, + mtimeMs: number + ): Promise { + const cacheKey = `${filePath}:${mtimeMs}:${teamName}:member-session`; + if (this.attributionCache.has(cacheKey)) { + return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null; + } + const attribution = await this.attributeMemberSession(filePath, teamName, knownMembers); + this.attributionCache.set(cacheKey, attribution); + if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) { + const oldestKey = this.attributionCache.keys().next().value; + if (oldestKey) this.attributionCache.delete(oldestKey); + } + return attribution; + } + private async parseSubagentSummary( filePath: string, projectId: string, @@ -1292,6 +1376,60 @@ export class TeamMemberLogsFinder { }; } + private async parseMemberSessionSummary( + filePath: string, + projectId: string, + sessionId: string, + targetMember: string, + teamName: string, + knownMembers: Set, + precomputedAttribution?: RootSessionAttribution + ): Promise { + const attribution = + precomputedAttribution ?? + (await this.attributeMemberSession(filePath, teamName, knownMembers)); + if (!attribution) { + return null; + } + + if (attribution.detectedMember.toLowerCase() !== targetMember.toLowerCase()) { + return null; + } + + const metadata = await this.streamFileMetadata(filePath); + const firstTimestamp = + metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); + const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; + + const startTime = new Date(firstTimestamp); + const endTime = new Date(lastTimestamp); + const durationMs = endTime.getTime() - startTime.getTime(); + + let isOngoing = false; + try { + const stat = await fs.stat(filePath); + isOngoing = Date.now() - stat.mtimeMs < 60_000; + } catch { + // ignore + } + + return { + kind: 'member_session', + sessionId, + projectId, + description: attribution.description || `${targetMember} session`, + memberName: targetMember, + startTime: firstTimestamp, + durationMs: Math.max(0, durationMs), + messageCount: metadata.messageCount, + isOngoing, + filePath, + lastOutputPreview: metadata.lastOutputPreview ?? undefined, + lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, + recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, + }; + } + /** * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals * and extract a human-readable description from the first user message. @@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder { return { detectedMember: best.member, description, firstTimestamp }; } + private async attributeMemberSession( + filePath: string, + teamName: string, + knownMembers: Set + ): Promise { + const lines: string[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let count = 0; + for await (const line of rl) { + if (count >= ATTRIBUTION_SCAN_LINES) break; + const trimmed = line.trim(); + if (!trimmed) continue; + lines.push(trimmed); + count++; + } + rl.close(); + stream.destroy(); + } catch { + return null; + } + + if (lines.length === 0) { + return null; + } + + const normalizedTeam = teamName.trim().toLowerCase(); + let detectedMember: string | null = null; + let description = ''; + let firstTimestamp: string | null = null; + let teamMatched = false; + + for (const line of lines) { + if (!firstTimestamp) { + firstTimestamp = this.extractTimestampFromLine(line); + } + + try { + const entry = JSON.parse(line) as Record; + const directTeamName = + typeof entry.teamName === 'string' ? entry.teamName.trim().toLowerCase() : null; + if (directTeamName === normalizedTeam) { + teamMatched = true; + } + + if (!detectedMember && typeof entry.agentName === 'string') { + const normalizedMember = entry.agentName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = entry.agentName.trim(); + } + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (!detectedMember && typeof processTeam?.memberName === 'string') { + const normalizedMember = processTeam.memberName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = processTeam.memberName.trim(); + } + } + if (!teamMatched) { + const processTeamName = + typeof processTeam?.teamName === 'string' + ? processTeam.teamName.trim().toLowerCase() + : null; + if (processTeamName === normalizedTeam) { + teamMatched = true; + } + } + + const role = this.extractRole(entry); + const textContent = this.extractTextContent(entry); + if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) { + if ( + textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) || + textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) || + textContent.toLowerCase().includes(`(${normalizedTeam})`) + ) { + teamMatched = true; + } + } + + if (role === 'user' && textContent && !description) { + const normalizedText = textContent.trim(); + if ( + normalizedText.length > 0 && + normalizedText !== 'Warmup' && + !normalizedText.startsWith('You are bootstrapping into team') && + !normalizedText.startsWith('Member briefing for ') + ) { + description = normalizedText.slice(0, 200); + } + } + } catch { + // ignore malformed lines + } + + if (teamMatched && detectedMember && description) { + break; + } + } + + if (!teamMatched || !detectedMember) { + return null; + } + + return { + detectedMember, + description: description || `${detectedMember} session`, + firstTimestamp, + }; + } + /** * Select the best detection signal by precedence. * Signals are collected in file order, so find() returns the earliest occurrence diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts new file mode 100644 index 00000000..4099ce1d --- /dev/null +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -0,0 +1,297 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream, type Dirent } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as readline from 'readline'; + +import { TeamConfigReader } from './TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptProjectResolver'); + +const SESSION_DISCOVERY_CACHE_TTL = 30_000; +const TEAM_AFFINITY_SCAN_LINES = 40; +const ROOT_DISCOVERY_CONCURRENCY = 12; + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +function isSessionDirectoryName(name: string): boolean { + return name !== 'memory' && !name.startsWith('.'); +} + +function extractTextContent(entry: Record): string | null { + if (typeof entry.content === 'string') { + return entry.content; + } + if (Array.isArray(entry.content)) { + const textParts = (entry.content as Record[]) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text as string); + if (textParts.length > 0) { + return textParts.join(' '); + } + } + if (entry.message && typeof entry.message === 'object') { + return extractTextContent(entry.message as Record); + } + return null; +} + +function extractDirectTeamName(entry: Record): string | null { + if (typeof entry.teamName === 'string') { + return entry.teamName.trim().toLowerCase(); + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (typeof processTeam?.teamName === 'string') { + return processTeam.teamName.trim().toLowerCase(); + } + + return null; +} + +function lineMentionsTeam(text: string, teamName: string): boolean { + const normalizedText = text.trim().toLowerCase(); + const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedText.includes(normalizedTeam)) { + return false; + } + return ( + normalizedText.includes(`on team "${normalizedTeam}"`) || + normalizedText.includes(`on team '${normalizedTeam}'`) || + normalizedText.includes(`team "${normalizedTeam}"`) || + normalizedText.includes(`team '${normalizedTeam}'`) || + normalizedText.includes(`(${normalizedTeam})`) + ); +} + +function collectKnownSessionIds(config: TeamConfig): string[] { + const knownSessionIds = new Set(); + const push = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + knownSessionIds.add(trimmed); + } + }; + + push(config.leadSessionId); + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + push(sessionId); + } + } + + return [...knownSessionIds]; +} + +export interface TeamTranscriptProjectContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; +} + +export class TeamTranscriptProjectResolver { + private readonly contextCache = new Map< + string, + { value: TeamTranscriptProjectContext; expiresAt: number } + >(); + + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise { + if (options?.forceRefresh) { + this.contextCache.delete(teamName); + } + + const cached = this.contextCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const { projectDir, projectId } = await this.resolveProjectDirectory(config); + const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); + const value = { projectDir, projectId, config, sessionIds }; + this.contextCache.set(teamName, { + value, + expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, + }); + return value; + } + + private async resolveProjectDirectory( + config: TeamConfig + ): Promise<{ projectDir: string; projectId: string }> { + const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + return { projectDir, projectId }; + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (!leadSessionId) { + return { projectDir, projectId }; + } + + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + + return { projectDir, projectId }; + } + + private async discoverSessionIds( + teamName: string, + projectDir: string, + config: TeamConfig + ): Promise { + const knownSessionIds = collectKnownSessionIds(config); + const [teamRootSessionIds, sessionDirIds] = await Promise.all([ + this.listTeamRootSessionIds(projectDir, teamName), + this.listSessionDirIds(projectDir), + ]); + + return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( + (left, right) => left.localeCompare(right) + ); + } + + private async listSessionDirIds(projectDir: string): Promise { + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + return dirEntries + .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + } + + private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + + const rootJsonlEntries = dirEntries.filter( + (entry) => entry.isFile() && entry.name.endsWith('.jsonl') + ); + const discovered = new Set(); + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < rootJsonlEntries.length) { + const index = nextIndex++; + const entry = rootJsonlEntries[index]; + const filePath = path.join(projectDir, entry.name); + if (!(await this.fileBelongsToTeam(filePath, teamName))) { + continue; + } + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () => + worker() + ) + ); + + return [...discovered]; + } + + private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const normalizedTeam = teamName.trim().toLowerCase(); + + try { + let inspected = 0; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + inspected += 1; + try { + const entry = JSON.parse(trimmed) as Record; + const directTeamName = extractDirectTeamName(entry); + if (directTeamName === normalizedTeam) { + return true; + } + + const textContent = extractTextContent(entry); + if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { + return true; + } + } catch { + // ignore malformed head lines + } + + if (inspected >= TEAM_AFFINITY_SCAN_LINES) { + break; + } + } + } catch { + return false; + } finally { + rl.close(); + stream.destroy(); + } + + return false; + } +} diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index 5a4416b9..c19e9ced 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -145,7 +145,7 @@ export class TeammateToolTracker { const state = this.stateByTeam.get(teamName); if (!state?.enabled || state.epoch !== expectedEpoch) return; - const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); + const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName); const currentState = this.stateByTeam.get(teamName); if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts index 1daee3cb..8dbd7e4d 100644 --- a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -1,27 +1,10 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver'; import type { TeamConfig } from '@shared/types'; -const logger = createLogger('Service:TeamTranscriptSourceLocator'); - -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - if (ch === 47 || ch === 92) { - end -= 1; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); -} - export interface TeamTranscriptSourceContext { projectDir: string; projectId: string; @@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext { } export class TeamTranscriptSourceLocator { - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver() + ) {} async getContext(teamName: string): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + const context = await this.projectResolver.getContext(teamName); + if (!context) { return null; } - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); - - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - try { - const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(getProjectsBasePath(), entry.name); - try { - await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); - projectDir = candidateDir; - projectId = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // best-effort fallback - } - } - } - - const sessionIds = await this.discoverSessionIds(projectDir, config); + const { projectDir, projectId, config, sessionIds } = context; const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); return { projectDir, projectId, config, sessionIds, transcriptFiles }; } @@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator { const context = await this.getContext(teamName); return context?.transcriptFiles ?? []; } - - private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { - const knownSessionIds = new Set(); - if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { - knownSessionIds.add(config.leadSessionId.trim()); - } - if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { - if (typeof sessionId === 'string' && sessionId.trim().length > 0) { - knownSessionIds.add(sessionId.trim()); - } - } - } - - let discoveredSessionDirs: string[] = []; - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - discoveredSessionDirs = dirEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - } catch { - logger.debug(`Cannot read transcript project dir: ${projectDir}`); - } - - if (knownSessionIds.size === 0) { - return discoveredSessionDirs.sort(); - } - - const verifiedSessionIds: string[] = []; - for (const sessionId of knownSessionIds) { - try { - const stat = await fs.stat(path.join(projectDir, sessionId)); - if (stat.isDirectory()) { - verifiedSessionIds.push(sessionId); - } - } catch { - // ignore stale config session - } - } - - return Array.from( - new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) - ).sort(); - } - private async listTranscriptFilesForSessions( projectDir: string, sessionIds: string[] @@ -160,6 +65,6 @@ export class TeamTranscriptSourceLocator { } } - return [...transcriptFiles].sort(); + return [...transcriptFiles].sort((left, right) => left.localeCompare(right)); } } diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 316c2c17..d559cc70 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -183,9 +183,13 @@ export const MemberLogsTab = ({ }, []); const getRowId = useCallback((log: MemberLogSummary): string => { - return log.kind === 'subagent' - ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + if (log.kind === 'subagent') { + return `subagent:${log.sessionId}:${log.subagentId}`; + } + if (log.kind === 'member_session') { + return `member:${log.sessionId}`; + } + return `lead:${log.sessionId}`; }, []); const sortedLogs = useMemo(() => { @@ -250,7 +254,9 @@ export const MemberLogsTab = ({ if (!shouldShowPreview) return null; if (showSubagentPreview) { - const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); + const candidates = sortedLogs.filter( + (l) => l.kind === 'subagent' || l.kind === 'member_session' + ); if (candidates.length === 0) return null; if (taskOwner) { @@ -515,6 +521,10 @@ export const MemberLogsTab = ({ ); return d?.chunks ?? null; } + if (log.kind === 'member_session') { + const d = await api.getSessionDetail(log.projectId, log.sessionId, options); + return d ? asEnhancedChunkArray(d.chunks) : null; + } const d = await api.getSessionDetail(log.projectId, log.sessionId, options); return d ? asEnhancedChunkArray(d.chunks) : null; }, @@ -766,7 +776,7 @@ const LogCard = ({ {log.description} - {log.kind === 'lead_session' && ( + {(log.kind === 'lead_session' || log.kind === 'member_session') && ( - Full team lead session logs — useful for global orchestration context, not - specific to this agent + {log.kind === 'lead_session' + ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent' + : 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'} )} @@ -838,7 +849,11 @@ const LogCard = ({
)} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d2f3935f..dcc8dfdf 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary { isOngoing: boolean; } -export type MemberLogKind = 'subagent' | 'lead_session'; +export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session'; export interface MemberLogSummaryBase { kind: MemberLogKind; @@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase { kind: 'lead_session'; } -export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary; +export interface MemberSessionLogSummary extends MemberLogSummaryBase { + kind: 'member_session'; +} + +export type MemberLogSummary = + | MemberSubagentLogSummary + | MemberLeadSessionLogSummary + | MemberSessionLogSummary; export interface FileLineStats { added: number; diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 9c7d559e..a8c02313 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => { expect(lead?.projectId).toBe(projectId); }); + it('returns root member sessions when config.projectPath is missing but member cwd is present', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-root'; + const projectPath = '/Users/test/signal-ops-root'; + const projectId = '-Users-test-signal-ops-root'; + const leadSessionId = 'lead-root'; + const memberSessionId = 'member-bob-root'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(projectRoot, { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }), + JSON.stringify({ + timestamp: '2026-04-15T14:02:05.000Z', + type: 'assistant', + teamName, + agentName: 'bob', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName, + taskId: 'task-root-1', + }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const bobLogs = await finder.findMemberLogs(teamName, 'bob'); + const taskLogs = await finder.findLogsForTask(teamName, 'task-root-1'); + const attributedFiles = await finder.listAttributedMemberFiles(teamName); + + expect(bobLogs).toHaveLength(1); + expect(bobLogs[0]?.kind).toBe('member_session'); + if (bobLogs[0]?.kind === 'member_session') { + expect(bobLogs[0].sessionId).toBe(memberSessionId); + expect(bobLogs[0].projectId).toBe(projectId); + expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob'); + expect(bobLogs[0].filePath).toBe(path.join(projectRoot, `${memberSessionId}.jsonl`)); + } + + expect( + taskLogs.some( + (log) => + log.kind === 'member_session' && + log.sessionId === memberSessionId && + log.memberName?.toLowerCase() === 'bob' + ) + ).toBe(true); + expect(attributedFiles).toEqual([ + { + memberName: 'bob', + sessionId: memberSessionId, + filePath: path.join(projectRoot, `${memberSessionId}.jsonl`), + mtimeMs: expect.any(Number), + }, + ]); + }); + it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); @@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'Bash', - input: { command: 'node \"teamctl.js\" --team demo task start task-42' }, + input: { command: 'node "teamctl.js" --team demo task start task-42' }, }, ], }, diff --git a/test/main/services/team/TeamTranscriptSourceLocator.test.ts b/test/main/services/team/TeamTranscriptSourceLocator.test.ts new file mode 100644 index 00000000..254ecb8f --- /dev/null +++ b/test/main/services/team/TeamTranscriptSourceLocator.test.ts @@ -0,0 +1,114 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { TeamTranscriptSourceLocator } from '../../../../src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +describe('TeamTranscriptSourceLocator', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('recovers projectPath from member cwd and includes only team-related root sessions', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-transcripts-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-test'; + const projectPath = '/Users/test/signal-ops'; + const projectId = '-Users-test-signal-ops'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'member-bob'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, 'unrelated-session.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:02.000Z', + type: 'user', + message: { role: 'user', content: 'Unrelated solo session' }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:03.000Z', + type: 'user', + message: { role: 'user', content: `You are bob, a developer on team "${teamName}".` }, + }) + '\n', + 'utf8' + ); + + const locator = new TeamTranscriptSourceLocator(); + const context = await locator.getContext(teamName); + const transcriptFiles = await locator.listTranscriptFiles(teamName); + + expect(context).not.toBeNull(); + expect(context?.projectId).toBe(projectId); + expect(context?.config.projectPath).toBe(projectPath); + expect(context?.sessionIds).toEqual(expect.arrayContaining([leadSessionId, memberSessionId])); + expect(context?.sessionIds).not.toContain('unrelated-session'); + expect(transcriptFiles).toEqual( + expect.arrayContaining([ + path.join(projectRoot, `${leadSessionId}.jsonl`), + path.join(projectRoot, `${memberSessionId}.jsonl`), + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + ]) + ); + expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl')); + }); +});