fix(team): recover root member session logs
This commit is contained in:
parent
03dda6b486
commit
8398d29fc0
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 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 {
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>();
|
||||
private readonly attributionCache = new Map<string, SubagentAttribution | null>();
|
||||
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<void> => {
|
||||
|
|
@ -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<MemberLogSummary | null> => {
|
||||
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<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;
|
||||
} 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<string>();
|
||||
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<string, (typeof refs)[0]>();
|
||||
const leadRefs: (typeof refs)[0][] = [];
|
||||
const memberSessionRefsByKey = new Map<string, (typeof refs)[0]>();
|
||||
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<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
||||
sessionIds: string[];
|
||||
knownMembers: Set<string>;
|
||||
} | 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<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 { config, projectDir, projectId, sessionIds } = context;
|
||||
|
||||
const knownMembers = new Set<string>(
|
||||
(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<Awaited<ReturnType<TeamConfigReader['getConfig']>>>
|
||||
): 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) {
|
||||
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<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(
|
||||
filePath: string,
|
||||
knownMembers: Set<string>,
|
||||
|
|
@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder {
|
|||
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(
|
||||
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<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
|
||||
* 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<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.
|
||||
* 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);
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TeamTranscriptSourceContext | null> {
|
||||
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<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(
|
||||
projectDir: 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 => {
|
||||
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 = ({
|
|||
<span className="truncate text-[var(--color-text)]" title={log.description}>
|
||||
{log.description}
|
||||
</span>
|
||||
{log.kind === 'lead_session' && (
|
||||
{(log.kind === 'lead_session' || log.kind === 'member_session') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
|
|
@ -777,8 +787,9 @@ const LogCard = ({
|
|||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-center">
|
||||
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'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -838,7 +849,11 @@ const LogCard = ({
|
|||
<div className="w-full min-w-0">
|
||||
<MemberExecutionLog
|
||||
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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
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