fix(team): recover root member session logs
This commit is contained in:
parent
03dda6b486
commit
2cfbfef3b3
9 changed files with 1066 additions and 319 deletions
|
|
@ -32,6 +32,51 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
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<TeamConfig, 'projectPath' | 'projectPathHistory' | 'members'>
|
||||||
|
): 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 {
|
interface LaunchStateSummary {
|
||||||
partialLaunchFailure?: true;
|
partialLaunchFailure?: true;
|
||||||
expectedMemberCount?: number;
|
expectedMemberCount?: number;
|
||||||
|
|
@ -250,10 +295,7 @@ export class TeamConfigReader {
|
||||||
typeof config.color === 'string' && config.color.trim().length > 0
|
typeof config.color === 'string' && config.color.trim().length > 0
|
||||||
? config.color
|
? config.color
|
||||||
: undefined;
|
: undefined;
|
||||||
projectPath =
|
projectPath = resolveProjectPathFromConfig(config);
|
||||||
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
|
|
||||||
? config.projectPath
|
|
||||||
: undefined;
|
|
||||||
leadSessionId =
|
leadSessionId =
|
||||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||||
? config.leadSessionId
|
? config.leadSessionId
|
||||||
|
|
@ -470,7 +512,8 @@ export class TeamConfigReader {
|
||||||
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
if (typeof config.name !== 'string' || config.name.trim() === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return config;
|
const resolvedProjectPath = resolveProjectPathFromConfig(config);
|
||||||
|
return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof FileReadTimeoutError) {
|
if (error instanceof FileReadTimeoutError) {
|
||||||
logger.warn(`[getConfig] ${error.message}`);
|
logger.warn(`[getConfig] ${error.message}`);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
|
||||||
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
|
|
@ -14,8 +13,13 @@ import {
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
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');
|
const logger = createLogger('Service:TeamMemberLogsFinder');
|
||||||
|
|
||||||
|
|
@ -76,23 +80,32 @@ interface SubagentAttribution {
|
||||||
firstTimestamp: string | null;
|
firstTimestamp: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimTrailingSlashes(value: string): string {
|
interface RootSessionAttribution {
|
||||||
let end = value.length;
|
detectedMember: string;
|
||||||
while (end > 0) {
|
description: string;
|
||||||
const ch = value.charCodeAt(end - 1);
|
firstTimestamp: string | null;
|
||||||
// '/' or '\'
|
|
||||||
if (ch === 47 || ch === 92) {
|
|
||||||
end--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return end === value.length ? value : value.slice(0, end);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogCandidate =
|
||||||
|
| {
|
||||||
|
kind: 'subagent';
|
||||||
|
filePath: string;
|
||||||
|
sessionId: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'member_session';
|
||||||
|
filePath: string;
|
||||||
|
sessionId: string;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class TeamMemberLogsFinder {
|
export class TeamMemberLogsFinder {
|
||||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
private readonly attributionCache = new Map<string, SubagentAttribution | null>();
|
private readonly attributionCache = new Map<
|
||||||
|
string,
|
||||||
|
SubagentAttribution | RootSessionAttribution | null
|
||||||
|
>();
|
||||||
private readonly discoveryCache = new Map<
|
private readonly discoveryCache = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -104,7 +117,10 @@ export class TeamMemberLogsFinder {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
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(
|
async findMemberLogs(
|
||||||
|
|
@ -134,8 +150,8 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collect and parallel-scan subagent files ──
|
// ── Collect and parallel-scan subagent files ──
|
||||||
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
|
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||||
const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null);
|
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
|
||||||
let nextIdx = 0;
|
let nextIdx = 0;
|
||||||
|
|
||||||
const scanWorker = async (): Promise<void> => {
|
const scanWorker = async (): Promise<void> => {
|
||||||
|
|
@ -152,17 +168,27 @@ export class TeamMemberLogsFinder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const summary = await this.parseSubagentSummary(
|
const summary =
|
||||||
c.filePath,
|
c.kind === 'subagent'
|
||||||
projectId,
|
? await this.parseSubagentSummary(
|
||||||
c.sessionId,
|
c.filePath,
|
||||||
c.fileName,
|
projectId,
|
||||||
memberName,
|
c.sessionId,
|
||||||
knownMembers
|
c.fileName,
|
||||||
);
|
memberName,
|
||||||
|
knownMembers
|
||||||
|
)
|
||||||
|
: await this.parseMemberSessionSummary(
|
||||||
|
c.filePath,
|
||||||
|
projectId,
|
||||||
|
c.sessionId,
|
||||||
|
memberName,
|
||||||
|
teamName,
|
||||||
|
knownMembers
|
||||||
|
);
|
||||||
if (summary) settled[idx] = summary;
|
if (summary) settled[idx] = summary;
|
||||||
} catch (err) {
|
} 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);
|
this.discoveryCache.delete(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovery = await this.discoverProjectSessions(teamName);
|
const discovery = await this.discoverProjectSessions(teamName, options);
|
||||||
if (!discovery) {
|
if (!discovery) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +284,7 @@ export class TeamMemberLogsFinder {
|
||||||
const tLead = performance.now();
|
const tLead = performance.now();
|
||||||
|
|
||||||
// ── Collect all subagent file candidates ──
|
// ── 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 ──
|
// ── Parallel scan with concurrency limit ──
|
||||||
const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null);
|
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)))
|
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
|
||||||
continue;
|
continue;
|
||||||
mentionHits++;
|
mentionHits++;
|
||||||
const attribution = await this.attributeSubagent(c.filePath, knownMembers);
|
const summary =
|
||||||
if (!attribution) continue;
|
c.kind === 'subagent'
|
||||||
const summary = await this.parseSubagentSummary(
|
? await (async (): Promise<MemberLogSummary | null> => {
|
||||||
c.filePath,
|
const attribution = await this.attributeSubagent(c.filePath, knownMembers);
|
||||||
projectId,
|
if (!attribution) return null;
|
||||||
c.sessionId,
|
return this.parseSubagentSummary(
|
||||||
c.fileName,
|
c.filePath,
|
||||||
attribution.detectedMember,
|
projectId,
|
||||||
knownMembers,
|
c.sessionId,
|
||||||
attribution
|
c.fileName,
|
||||||
);
|
attribution.detectedMember,
|
||||||
|
knownMembers,
|
||||||
|
attribution
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: await (async (): Promise<MemberLogSummary | null> => {
|
||||||
|
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;
|
if (summary) settled[idx] = summary;
|
||||||
} catch (err) {
|
} 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 =
|
const key =
|
||||||
log.kind === 'subagent'
|
log.kind === 'subagent'
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||||
: `lead:${log.sessionId}`;
|
: log.kind === 'member_session'
|
||||||
|
? `member:${log.sessionId}`
|
||||||
|
: `lead:${log.sessionId}`;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
for (const log of filteredOwnerLogs) {
|
for (const log of filteredOwnerLogs) {
|
||||||
const key =
|
const key =
|
||||||
log.kind === 'subagent'
|
log.kind === 'subagent'
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||||
: `lead:${log.sessionId}`;
|
: log.kind === 'member_session'
|
||||||
|
? `member:${log.sessionId}`
|
||||||
|
: `lead:${log.sessionId}`;
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
results.push(log);
|
results.push(log);
|
||||||
|
|
@ -455,16 +506,28 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
const sinceMs = this.deriveSinceMs(options);
|
const sinceMs = this.deriveSinceMs(options);
|
||||||
const { projectDir, config, sessionIds, knownMembers } = discovery;
|
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<string>();
|
const seen = new Set<string>();
|
||||||
const leadMemberName =
|
const leadMemberName =
|
||||||
config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead';
|
config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead';
|
||||||
|
|
||||||
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
|
const pushRef = (
|
||||||
const key = `${memberName.toLowerCase()}:${filePath}`;
|
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;
|
if (seen.has(key)) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
refs.push({ filePath, memberName, sortTime });
|
refs.push({ kind, filePath, memberName, sessionId, sortTime });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.leadSessionId) {
|
if (config.leadSessionId) {
|
||||||
|
|
@ -473,7 +536,13 @@ export class TeamMemberLogsFinder {
|
||||||
await fs.access(leadJsonl);
|
await fs.access(leadJsonl);
|
||||||
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
|
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
|
||||||
const firstTimestamp = await this.probeFirstTimestamp(leadJsonl);
|
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 {
|
} catch {
|
||||||
// file missing or unreadable
|
// file missing or unreadable
|
||||||
|
|
@ -482,7 +551,7 @@ export class TeamMemberLogsFinder {
|
||||||
const tLead = performance.now();
|
const tLead = performance.now();
|
||||||
|
|
||||||
// ── Collect all subagent file candidates ──
|
// ── 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 ──
|
// ── Parallel scan with concurrency limit ──
|
||||||
let nextIdx = 0;
|
let nextIdx = 0;
|
||||||
|
|
@ -496,15 +565,20 @@ export class TeamMemberLogsFinder {
|
||||||
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
|
if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs)))
|
||||||
continue;
|
continue;
|
||||||
mentionHits++;
|
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;
|
if (!attribution) continue;
|
||||||
pushRef(
|
pushRef(
|
||||||
c.filePath,
|
c.filePath,
|
||||||
attribution.detectedMember,
|
attribution.detectedMember,
|
||||||
await this.getSortTime(c.filePath, attribution.firstTimestamp)
|
await this.getSortTime(c.filePath, attribution.firstTimestamp),
|
||||||
|
c.kind,
|
||||||
|
c.sessionId
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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(
|
pushRef(
|
||||||
log.filePath,
|
log.filePath,
|
||||||
log.memberName ?? normalizedOwner,
|
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<string, (typeof refs)[0]>();
|
const refsByKey = new Map<string, (typeof refs)[0]>();
|
||||||
const leadRefs: (typeof refs)[0][] = [];
|
const leadRefs: (typeof refs)[0][] = [];
|
||||||
|
const memberSessionRefsByKey = new Map<string, (typeof refs)[0]>();
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) {
|
if (ref.kind === 'lead_session') {
|
||||||
leadRefs.push(ref);
|
leadRefs.push(ref);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parts = ref.filePath.split(path.sep);
|
if (ref.kind === 'member_session') {
|
||||||
const subagentsIdx = parts.lastIndexOf('subagents');
|
const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`;
|
||||||
const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : '';
|
const existing = memberSessionRefsByKey.get(key);
|
||||||
const key = `${sessionId}:${ref.memberName.toLowerCase()}`;
|
if (!existing || ref.sortTime > existing.sortTime) {
|
||||||
|
memberSessionRefsByKey.set(key, ref);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`;
|
||||||
const existing = refsByKey.get(key);
|
const existing = refsByKey.get(key);
|
||||||
if (!existing || ref.sortTime > existing.sortTime) {
|
if (!existing || ref.sortTime > existing.sortTime) {
|
||||||
refsByKey.set(key, ref);
|
refsByKey.set(key, ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refs.length = 0;
|
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);
|
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
|
||||||
|
|
@ -650,43 +734,32 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||||
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
|
for (const candidate of candidates) {
|
||||||
|
let mtimeMs = 0;
|
||||||
let files: string[];
|
|
||||||
try {
|
try {
|
||||||
files = await fs.readdir(subagentsDir);
|
mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const attribution =
|
||||||
for (const file of files) {
|
candidate.kind === 'subagent'
|
||||||
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs)
|
||||||
if (file.startsWith('agent-acompact')) continue;
|
: await this.getCachedMemberSessionAttribution(
|
||||||
|
candidate.filePath,
|
||||||
const filePath = path.join(subagentsDir, file);
|
teamName,
|
||||||
// Quick attribution check — only Phase 1 (no full-file streaming)
|
knownMembers,
|
||||||
let mtimeMs = 0;
|
mtimeMs
|
||||||
try {
|
);
|
||||||
mtimeMs = (await fs.stat(filePath)).mtimeMs;
|
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
||||||
} catch {
|
paths.push(candidate.filePath);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const attribution = await this.getCachedSubagentAttribution(
|
|
||||||
filePath,
|
|
||||||
knownMembers,
|
|
||||||
mtimeMs
|
|
||||||
);
|
|
||||||
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
|
||||||
paths.push(filePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAttributedSubagentFiles(
|
async listAttributedMemberFiles(
|
||||||
teamName: string
|
teamName: string
|
||||||
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> {
|
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> {
|
||||||
const discovery = await this.discoverProjectSessions(teamName);
|
const discovery = await this.discoverProjectSessions(teamName);
|
||||||
|
|
@ -697,13 +770,7 @@ export class TeamMemberLogsFinder {
|
||||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||||
? config.leadSessionId.trim()
|
? config.leadSessionId.trim()
|
||||||
: null;
|
: null;
|
||||||
// Live teammate tool tracking should follow the current team run, not historical
|
const candidates = await this.collectLogCandidates(projectDir, sessionIds, config);
|
||||||
// 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 results: {
|
const results: {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -714,12 +781,27 @@ export class TeamMemberLogsFinder {
|
||||||
const settled = await Promise.all(
|
const settled = await Promise.all(
|
||||||
candidates.map(async (candidate) => {
|
candidates.map(async (candidate) => {
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
candidate.kind === 'subagent' &&
|
||||||
|
currentLeadSessionId &&
|
||||||
|
candidate.sessionId !== currentLeadSessionId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const stat = await fs.stat(candidate.filePath);
|
const stat = await fs.stat(candidate.filePath);
|
||||||
const attribution = await this.getCachedSubagentAttribution(
|
const attribution =
|
||||||
candidate.filePath,
|
candidate.kind === 'subagent'
|
||||||
knownMembers,
|
? await this.getCachedSubagentAttribution(
|
||||||
stat.mtimeMs
|
candidate.filePath,
|
||||||
);
|
knownMembers,
|
||||||
|
stat.mtimeMs
|
||||||
|
)
|
||||||
|
: await this.getCachedMemberSessionAttribution(
|
||||||
|
candidate.filePath,
|
||||||
|
teamName,
|
||||||
|
knownMembers,
|
||||||
|
stat.mtimeMs
|
||||||
|
);
|
||||||
if (!attribution) return null;
|
if (!attribution) return null;
|
||||||
return {
|
return {
|
||||||
memberName: attribution.detectedMember,
|
memberName: attribution.detectedMember,
|
||||||
|
|
@ -737,7 +819,34 @@ export class TeamMemberLogsFinder {
|
||||||
if (item) results.push(item);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async discoverProjectSessions(teamName: string): Promise<{
|
private async discoverProjectSessions(
|
||||||
|
teamName: string,
|
||||||
|
options?: { forceRefresh?: boolean }
|
||||||
|
): Promise<{
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
knownMembers: Set<string>;
|
knownMembers: Set<string>;
|
||||||
} | null> {
|
} | null> {
|
||||||
// Check discovery cache — avoids re-reading config/dirs within rapid successive calls
|
if (options?.forceRefresh) {
|
||||||
const cached = this.discoveryCache.get(teamName);
|
this.discoveryCache.delete(teamName);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
} else {
|
||||||
return cached.result;
|
// 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);
|
const context = await this.projectResolver.getContext(teamName, options);
|
||||||
if (!config?.projectPath) {
|
if (!context) {
|
||||||
logger.debug(`No projectPath for team "${teamName}"`);
|
logger.debug(`No transcript context for team "${teamName}"`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const { config, projectDir, projectId, sessionIds } = context;
|
||||||
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<string>();
|
|
||||||
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 knownMembers = new Set<string>(
|
const knownMembers = new Set<string>(
|
||||||
(config.members ?? [])
|
(config.members ?? [])
|
||||||
|
|
@ -927,16 +971,42 @@ export class TeamMemberLogsFinder {
|
||||||
return { ...discovery, isLeadMember };
|
return { ...discovery, isLeadMember };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async collectLogCandidates(
|
||||||
* Collect all subagent JSONL file candidates across session directories.
|
|
||||||
* Filters out non-agent files and compact files (agent-acompact*).
|
|
||||||
*/
|
|
||||||
private async collectSubagentCandidates(
|
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
sessionIds: string[]
|
sessionIds: string[],
|
||||||
): Promise<{ filePath: string; sessionId: string; fileName: string }[]> {
|
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>
|
||||||
const candidates: { filePath: string; sessionId: string; fileName: string }[] = [];
|
): Promise<LogCandidate[]> {
|
||||||
|
const candidates: LogCandidate[] = [];
|
||||||
|
const leadSessionIds = new Set<string>();
|
||||||
|
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) {
|
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');
|
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
|
||||||
let dirFiles: string[];
|
let dirFiles: string[];
|
||||||
try {
|
try {
|
||||||
|
|
@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder {
|
||||||
for (const f of dirFiles) {
|
for (const f of dirFiles) {
|
||||||
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
|
if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact'))
|
||||||
continue;
|
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;
|
return candidates;
|
||||||
|
|
@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listSessionDirs(projectDir: string): Promise<string[]> {
|
|
||||||
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(
|
private async getCachedSubagentAttribution(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
knownMembers: Set<string>,
|
knownMembers: Set<string>,
|
||||||
|
|
@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder {
|
||||||
return attribution;
|
return attribution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCachedMemberSessionAttribution(
|
||||||
|
filePath: string,
|
||||||
|
teamName: string,
|
||||||
|
knownMembers: Set<string>,
|
||||||
|
mtimeMs: number
|
||||||
|
): Promise<RootSessionAttribution | null> {
|
||||||
|
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(
|
private async parseSubagentSummary(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectId: 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<string>,
|
||||||
|
precomputedAttribution?: RootSessionAttribution
|
||||||
|
): Promise<MemberSessionLogSummary | null> {
|
||||||
|
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
|
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
|
||||||
* and extract a human-readable description from the first user message.
|
* and extract a human-readable description from the first user message.
|
||||||
|
|
@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder {
|
||||||
return { detectedMember: best.member, description, firstTimestamp };
|
return { detectedMember: best.member, description, firstTimestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async attributeMemberSession(
|
||||||
|
filePath: string,
|
||||||
|
teamName: string,
|
||||||
|
knownMembers: Set<string>
|
||||||
|
): Promise<RootSessionAttribution | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown> | undefined;
|
||||||
|
const processTeam = process?.team as Record<string, unknown> | 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.
|
* Select the best detection signal by precedence.
|
||||||
* Signals are collected in file order, so find() returns the earliest occurrence
|
* Signals are collected in file order, so find() returns the earliest occurrence
|
||||||
|
|
|
||||||
297
src/main/services/team/TeamTranscriptProjectResolver.ts
Normal file
297
src/main/services/team/TeamTranscriptProjectResolver.ts
Normal file
|
|
@ -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, unknown>): string | null {
|
||||||
|
if (typeof entry.content === 'string') {
|
||||||
|
return entry.content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(entry.content)) {
|
||||||
|
const textParts = (entry.content as Record<string, unknown>[])
|
||||||
|
.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<string, unknown>);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDirectTeamName(entry: Record<string, unknown>): string | null {
|
||||||
|
if (typeof entry.teamName === 'string') {
|
||||||
|
return entry.teamName.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const process = entry.process as Record<string, unknown> | undefined;
|
||||||
|
const processTeam = process?.team as Record<string, unknown> | 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<string>();
|
||||||
|
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<TeamTranscriptProjectContext | null> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string>();
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
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<boolean> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -145,7 +145,7 @@ export class TeammateToolTracker {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
if (!state?.enabled || state.epoch !== expectedEpoch) return;
|
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);
|
const currentState = this.stateByTeam.get(teamName);
|
||||||
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;
|
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { TeamConfigReader } from '../../TeamConfigReader';
|
import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
import type { TeamConfig } from '@shared/types';
|
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 {
|
export interface TeamTranscriptSourceContext {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamTranscriptSourceLocator {
|
export class TeamTranscriptSourceLocator {
|
||||||
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
|
constructor(
|
||||||
|
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver()
|
||||||
|
) {}
|
||||||
|
|
||||||
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> {
|
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const context = await this.projectResolver.getContext(teamName);
|
||||||
if (!config?.projectPath) {
|
if (!context) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath);
|
const { projectDir, projectId, config, sessionIds } = context;
|
||||||
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 transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds);
|
const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds);
|
||||||
return { projectDir, projectId, config, sessionIds, transcriptFiles };
|
return { projectDir, projectId, config, sessionIds, transcriptFiles };
|
||||||
}
|
}
|
||||||
|
|
@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator {
|
||||||
const context = await this.getContext(teamName);
|
const context = await this.getContext(teamName);
|
||||||
return context?.transcriptFiles ?? [];
|
return context?.transcriptFiles ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise<string[]> {
|
|
||||||
const knownSessionIds = new Set<string>();
|
|
||||||
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(
|
private async listTranscriptFilesForSessions(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
|
|
@ -160,6 +65,6 @@ export class TeamTranscriptSourceLocator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...transcriptFiles].sort();
|
return [...transcriptFiles].sort((left, right) => left.localeCompare(right));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,9 +183,13 @@ export const MemberLogsTab = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRowId = useCallback((log: MemberLogSummary): string => {
|
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||||
return log.kind === 'subagent'
|
if (log.kind === 'subagent') {
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
return `subagent:${log.sessionId}:${log.subagentId}`;
|
||||||
: `lead:${log.sessionId}`;
|
}
|
||||||
|
if (log.kind === 'member_session') {
|
||||||
|
return `member:${log.sessionId}`;
|
||||||
|
}
|
||||||
|
return `lead:${log.sessionId}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sortedLogs = useMemo(() => {
|
const sortedLogs = useMemo(() => {
|
||||||
|
|
@ -250,7 +254,9 @@ export const MemberLogsTab = ({
|
||||||
if (!shouldShowPreview) return null;
|
if (!shouldShowPreview) return null;
|
||||||
|
|
||||||
if (showSubagentPreview) {
|
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 (candidates.length === 0) return null;
|
||||||
|
|
||||||
if (taskOwner) {
|
if (taskOwner) {
|
||||||
|
|
@ -515,6 +521,10 @@ export const MemberLogsTab = ({
|
||||||
);
|
);
|
||||||
return d?.chunks ?? null;
|
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);
|
const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
|
||||||
return d ? asEnhancedChunkArray(d.chunks) : null;
|
return d ? asEnhancedChunkArray(d.chunks) : null;
|
||||||
},
|
},
|
||||||
|
|
@ -766,7 +776,7 @@ const LogCard = ({
|
||||||
<span className="truncate text-[var(--color-text)]" title={log.description}>
|
<span className="truncate text-[var(--color-text)]" title={log.description}>
|
||||||
{log.description}
|
{log.description}
|
||||||
</span>
|
</span>
|
||||||
{log.kind === 'lead_session' && (
|
{(log.kind === 'lead_session' || log.kind === 'member_session') && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
|
|
@ -777,8 +787,9 @@ const LogCard = ({
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-[240px] text-center">
|
<TooltipContent side="top" className="max-w-[240px] text-center">
|
||||||
Full team lead session logs — useful for global orchestration context, not
|
{log.kind === 'lead_session'
|
||||||
specific to this agent
|
? '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'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
@ -838,7 +849,11 @@ const LogCard = ({
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<MemberExecutionLog
|
<MemberExecutionLog
|
||||||
chunks={detailChunks}
|
chunks={detailChunks}
|
||||||
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}
|
memberName={
|
||||||
|
log.kind === 'lead_session' || log.kind === 'member_session'
|
||||||
|
? (log.memberName ?? undefined)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary {
|
||||||
isOngoing: boolean;
|
isOngoing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MemberLogKind = 'subagent' | 'lead_session';
|
export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session';
|
||||||
|
|
||||||
export interface MemberLogSummaryBase {
|
export interface MemberLogSummaryBase {
|
||||||
kind: MemberLogKind;
|
kind: MemberLogKind;
|
||||||
|
|
@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase {
|
||||||
kind: 'lead_session';
|
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 {
|
export interface FileLineStats {
|
||||||
added: number;
|
added: number;
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
expect(lead?.projectId).toBe(projectId);
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
{
|
{
|
||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
input: { command: 'node \"teamctl.js\" --team demo task start task-42' },
|
input: { command: 'node "teamctl.js" --team demo task start task-42' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
114
test/main/services/team/TeamTranscriptSourceLocator.test.ts
Normal file
114
test/main/services/team/TeamTranscriptSourceLocator.test.ts
Normal file
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue