fix(team): scope log source tracking
This commit is contained in:
parent
2fde1ad7fc
commit
1339629da2
8 changed files with 990 additions and 85 deletions
|
|
@ -8,16 +8,27 @@ import {
|
||||||
computeTaskChangePresenceProjectFingerprint,
|
computeTaskChangePresenceProjectFingerprint,
|
||||||
normalizeTaskChangePresenceFilePath,
|
normalizeTaskChangePresenceFilePath,
|
||||||
} from './taskChangePresenceUtils';
|
} from './taskChangePresenceUtils';
|
||||||
|
import {
|
||||||
|
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME,
|
||||||
|
BOARD_TASK_CHANGES_DIRNAME,
|
||||||
|
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
|
||||||
|
BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX,
|
||||||
|
MAX_PENDING_UNKNOWN_ROOT_REFRESH_ATTEMPTS,
|
||||||
|
MAX_PENDING_UNKNOWN_ROOT_SESSIONS,
|
||||||
|
PENDING_UNKNOWN_ROOT_SESSION_TTL_MS,
|
||||||
|
classifyLogSourceWatcherEvent,
|
||||||
|
getRelativeLogSourceParts,
|
||||||
|
isAgentTranscriptFileName,
|
||||||
|
normalizeLogSourceSessionId,
|
||||||
|
} from './teamLogSourceWatchScope';
|
||||||
|
|
||||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import type { TeamLogSourceLiveContext, TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import type { TeamChangeEvent } from '@shared/types';
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
import type { FSWatcher } from 'chokidar';
|
import type { FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamLogSourceTracker');
|
const logger = createLogger('Service:TeamLogSourceTracker');
|
||||||
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
const CONTEXT_REFRESH_DEBOUNCE_MS = 300;
|
||||||
const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
|
const PENDING_CONTEXT_REFRESH_RETRY_MS = 1_000;
|
||||||
const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
|
|
||||||
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
|
||||||
|
|
||||||
interface TeamLogSourceSnapshot {
|
interface TeamLogSourceSnapshot {
|
||||||
projectFingerprint: string | null;
|
projectFingerprint: string | null;
|
||||||
|
|
@ -33,7 +44,11 @@ export type TeamLogSourceTrackingConsumer =
|
||||||
interface TrackingState {
|
interface TrackingState {
|
||||||
watcher: FSWatcher | null;
|
watcher: FSWatcher | null;
|
||||||
projectDir: string | null;
|
projectDir: string | null;
|
||||||
|
activeContext: TeamLogSourceLiveContext | null;
|
||||||
|
scopedSessionIds: Set<string>;
|
||||||
|
pendingUnknownSessionIds: Map<string, PendingUnknownSessionCandidate>;
|
||||||
refreshTimer: ReturnType<typeof setTimeout> | null;
|
refreshTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
contextRefreshTimer: ReturnType<typeof setTimeout> | null;
|
||||||
initializePromise: Promise<TeamLogSourceSnapshot> | null;
|
initializePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||||
initializeVersion: number | null;
|
initializeVersion: number | null;
|
||||||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||||
|
|
@ -43,6 +58,12 @@ interface TrackingState {
|
||||||
lifecycleVersion: number;
|
lifecycleVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingUnknownSessionCandidate {
|
||||||
|
sessionId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
refreshAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
type DecodedFreshnessTaskId =
|
type DecodedFreshnessTaskId =
|
||||||
| { kind: 'task-id'; taskId: string }
|
| { kind: 'task-id'; taskId: string }
|
||||||
| { kind: 'opaque-safe-segment' }
|
| { kind: 'opaque-safe-segment' }
|
||||||
|
|
@ -52,14 +73,30 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
||||||
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldIgnoreLogSourceWatcherPath(projectDir: string, watchedPath: string): boolean {
|
export function shouldIgnoreLogSourceWatcherPath(
|
||||||
const relativePath = path.relative(projectDir, watchedPath);
|
projectDir: string,
|
||||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
watchedPath: string,
|
||||||
|
_scope?: { scopedSessionIds?: ReadonlySet<string> }
|
||||||
|
): boolean {
|
||||||
|
const parts = getRelativeLogSourceParts(projectDir, watchedPath);
|
||||||
|
if (!parts) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = relativePath.split(/[/\\]+/).filter(Boolean);
|
const first = parts[0];
|
||||||
return parts[0] === BOARD_TASK_CHANGES_DIRNAME;
|
if (first === BOARD_TASK_CHANGES_DIRNAME) return true;
|
||||||
|
if (parts.includes('tool-results')) return true;
|
||||||
|
if (parts.includes('memory')) return true;
|
||||||
|
if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false;
|
||||||
|
if (first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) return false;
|
||||||
|
|
||||||
|
if (parts.length >= 2 && parts[1] === 'subagents') {
|
||||||
|
if (parts.length === 2) return false;
|
||||||
|
if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamLogSourceTracker {
|
export class TeamLogSourceTracker {
|
||||||
|
|
@ -149,7 +186,11 @@ export class TeamLogSourceTracker {
|
||||||
const created: TrackingState = {
|
const created: TrackingState = {
|
||||||
watcher: null,
|
watcher: null,
|
||||||
projectDir: null,
|
projectDir: null,
|
||||||
|
activeContext: null,
|
||||||
|
scopedSessionIds: new Set(),
|
||||||
|
pendingUnknownSessionIds: new Map(),
|
||||||
refreshTimer: null,
|
refreshTimer: null,
|
||||||
|
contextRefreshTimer: null,
|
||||||
initializePromise: null,
|
initializePromise: null,
|
||||||
initializeVersion: null,
|
initializeVersion: null,
|
||||||
recomputePromise: null,
|
recomputePromise: null,
|
||||||
|
|
@ -205,6 +246,10 @@ export class TeamLogSourceTracker {
|
||||||
clearTimeout(state.refreshTimer);
|
clearTimeout(state.refreshTimer);
|
||||||
state.refreshTimer = null;
|
state.refreshTimer = null;
|
||||||
}
|
}
|
||||||
|
if (state.contextRefreshTimer) {
|
||||||
|
clearTimeout(state.contextRefreshTimer);
|
||||||
|
state.contextRefreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.watcher) {
|
if (state.watcher) {
|
||||||
await state.watcher.close().catch(() => undefined);
|
await state.watcher.close().catch(() => undefined);
|
||||||
|
|
@ -212,6 +257,9 @@ export class TeamLogSourceTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.projectDir = null;
|
state.projectDir = null;
|
||||||
|
state.activeContext = null;
|
||||||
|
state.scopedSessionIds.clear();
|
||||||
|
state.pendingUnknownSessionIds.clear();
|
||||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||||
return { ...state.snapshot };
|
return { ...state.snapshot };
|
||||||
}
|
}
|
||||||
|
|
@ -231,24 +279,27 @@ export class TeamLogSourceTracker {
|
||||||
): Promise<TeamLogSourceSnapshot> {
|
): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
const previousGeneration = state.snapshot.logSourceGeneration;
|
const previousGeneration = state.snapshot.logSourceGeneration;
|
||||||
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
|
const context = await this.logsFinder.getLiveLogSourceWatchContext(teamName, {
|
||||||
forceRefresh: true,
|
forceRefresh: true,
|
||||||
});
|
});
|
||||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
return this.getOrCreateState(teamName).snapshot;
|
return this.getOrCreateState(teamName).snapshot;
|
||||||
}
|
}
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
state.activeContext = null;
|
||||||
|
state.scopedSessionIds.clear();
|
||||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||||
await this.rebuildWatcher(teamName, null, expectedVersion);
|
await this.rebuildWatcher(teamName, null, expectedVersion);
|
||||||
return state.snapshot;
|
return state.snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.activeContext = context;
|
||||||
const snapshot = await this.computeSnapshot(context);
|
const snapshot = await this.computeSnapshot(context);
|
||||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
return this.getOrCreateState(teamName).snapshot;
|
return this.getOrCreateState(teamName).snapshot;
|
||||||
}
|
}
|
||||||
state.snapshot = snapshot;
|
state.snapshot = snapshot;
|
||||||
await this.rebuildWatcher(teamName, context.projectDir, expectedVersion);
|
await this.rebuildWatcher(teamName, context, expectedVersion);
|
||||||
if (
|
if (
|
||||||
this.isTrackingCurrent(teamName, expectedVersion) &&
|
this.isTrackingCurrent(teamName, expectedVersion) &&
|
||||||
state.snapshot.logSourceGeneration &&
|
state.snapshot.logSourceGeneration &&
|
||||||
|
|
@ -261,7 +312,7 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
private async rebuildWatcher(
|
private async rebuildWatcher(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectDir: string | null,
|
context: TeamLogSourceLiveContext | null,
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = this.stateByTeam.get(teamName);
|
const state = this.stateByTeam.get(teamName);
|
||||||
|
|
@ -272,17 +323,15 @@ export class TeamLogSourceTracker {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.projectDir === projectDir && state.watcher) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.watcher) {
|
if (state.watcher) {
|
||||||
await state.watcher.close().catch(() => undefined);
|
await state.watcher.close().catch(() => undefined);
|
||||||
state.watcher = null;
|
state.watcher = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.projectDir = projectDir;
|
state.projectDir = context?.projectDir ?? null;
|
||||||
if (!projectDir) {
|
state.scopedSessionIds.clear();
|
||||||
|
if (!context?.projectDir) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,26 +340,56 @@ export class TeamLogSourceTracker {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.watcher = watch(projectDir, {
|
await this.ensureLogSourceFreshnessDirs(context.projectDir).catch((error) => {
|
||||||
|
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
||||||
|
context.projectDir,
|
||||||
|
context.watchSessionIds,
|
||||||
|
this.getPendingUnknownSessionIds(state)
|
||||||
|
);
|
||||||
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.scopedSessionIds = scopedSessionIds;
|
||||||
|
|
||||||
|
state.watcher = watch(targets, {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
ignorePermissionErrors: true,
|
ignorePermissionErrors: true,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
depth: 3,
|
depth: 0,
|
||||||
ignored: (watchedPath) => shouldIgnoreLogSourceWatcherPath(projectDir, watchedPath),
|
ignored: (watchedPath) =>
|
||||||
|
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds }),
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: 250,
|
stabilityThreshold: 250,
|
||||||
pollInterval: 50,
|
pollInterval: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduleRecompute = (changedPath?: string): void => {
|
const handleWatcherEvent = (
|
||||||
|
eventName: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir',
|
||||||
|
changedPath?: string
|
||||||
|
): void => {
|
||||||
const current = this.stateByTeam.get(teamName);
|
const current = this.stateByTeam.get(teamName);
|
||||||
if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
|
if (
|
||||||
|
!changedPath ||
|
||||||
|
!current ||
|
||||||
|
this.getActiveConsumerCount(current) === 0 ||
|
||||||
|
!current.projectDir
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
const action = classifyLogSourceWatcherEvent({
|
||||||
changedPath &&
|
projectDir: current.projectDir,
|
||||||
(this.handleTaskFreshnessSignalChange(
|
changedPath,
|
||||||
|
eventName,
|
||||||
|
scopedSessionIds: current.scopedSessionIds,
|
||||||
|
pendingUnknownSessionIds: new Set(current.pendingUnknownSessionIds.keys()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action.kind === 'task-freshness') {
|
||||||
|
this.handleTaskFreshnessSignalChange(
|
||||||
teamName,
|
teamName,
|
||||||
current.projectDir,
|
current.projectDir,
|
||||||
changedPath,
|
changedPath,
|
||||||
|
|
@ -321,29 +400,234 @@ export class TeamLogSourceTracker {
|
||||||
current.projectDir,
|
current.projectDir,
|
||||||
changedPath,
|
changedPath,
|
||||||
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
|
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
|
||||||
))
|
);
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.refreshTimer) {
|
|
||||||
clearTimeout(current.refreshTimer);
|
if (action.kind === 'context-refresh') {
|
||||||
|
this.scheduleContextRefresh(teamName, action.candidateSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.kind === 'scoped-recompute') {
|
||||||
|
this.scheduleScopedRecompute(teamName);
|
||||||
}
|
}
|
||||||
current.refreshTimer = setTimeout(() => {
|
|
||||||
current.refreshTimer = null;
|
|
||||||
void this.recompute(teamName);
|
|
||||||
}, 300);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state.watcher.on('add', scheduleRecompute);
|
state.watcher.on('add', (changedPath) => handleWatcherEvent('add', changedPath));
|
||||||
state.watcher.on('change', scheduleRecompute);
|
state.watcher.on('change', (changedPath) => handleWatcherEvent('change', changedPath));
|
||||||
state.watcher.on('unlink', scheduleRecompute);
|
state.watcher.on('unlink', (changedPath) => handleWatcherEvent('unlink', changedPath));
|
||||||
state.watcher.on('addDir', scheduleRecompute);
|
state.watcher.on('addDir', (changedPath) => handleWatcherEvent('addDir', changedPath));
|
||||||
state.watcher.on('unlinkDir', scheduleRecompute);
|
state.watcher.on('unlinkDir', (changedPath) => handleWatcherEvent('unlinkDir', changedPath));
|
||||||
state.watcher.on('error', (error) => {
|
state.watcher.on('error', (error) => {
|
||||||
logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`);
|
logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureLogSourceFreshnessDirs(projectDir: string): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }),
|
||||||
|
fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildScopedWatchTargets(
|
||||||
|
projectDir: string,
|
||||||
|
confirmedSessionIds: readonly string[],
|
||||||
|
pendingRootSessionIds: readonly string[]
|
||||||
|
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
||||||
|
const targets = new Set<string>();
|
||||||
|
const scopedSessionIds = new Set<string>();
|
||||||
|
|
||||||
|
targets.add(projectDir);
|
||||||
|
targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
||||||
|
targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
||||||
|
|
||||||
|
for (const rawSessionId of confirmedSessionIds) {
|
||||||
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
|
if (!sessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scopedSessionIds.add(sessionId);
|
||||||
|
|
||||||
|
const rootTranscript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
const sessionDir = path.join(projectDir, sessionId);
|
||||||
|
const subagentsDir = path.join(sessionDir, 'subagents');
|
||||||
|
|
||||||
|
if (await this.isFile(rootTranscript)) targets.add(rootTranscript);
|
||||||
|
if (await this.isDirectory(sessionDir)) targets.add(sessionDir);
|
||||||
|
if (await this.isDirectory(subagentsDir)) targets.add(subagentsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawSessionId of pendingRootSessionIds) {
|
||||||
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
|
if (!sessionId || scopedSessionIds.has(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rootTranscript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
if (await this.isFile(rootTranscript)) targets.add(rootTranscript);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { targets: [...targets], scopedSessionIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isFile(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await fs.stat(targetPath)).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isDirectory(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await fs.stat(targetPath)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPendingUnknownSessionIds(state: TrackingState): string[] {
|
||||||
|
this.prunePendingUnknownSessions(state);
|
||||||
|
return [...state.pendingUnknownSessionIds.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberPendingUnknownSession(
|
||||||
|
state: TrackingState,
|
||||||
|
rawSessionId: string | undefined
|
||||||
|
): void {
|
||||||
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
|
if (!sessionId || state.scopedSessionIds.has(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
state.pendingUnknownSessionIds.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
expiresAt: now + PENDING_UNKNOWN_ROOT_SESSION_TTL_MS,
|
||||||
|
refreshAttempts: state.pendingUnknownSessionIds.get(sessionId)?.refreshAttempts ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
while (state.pendingUnknownSessionIds.size > MAX_PENDING_UNKNOWN_ROOT_SESSIONS) {
|
||||||
|
const oldest = [...state.pendingUnknownSessionIds.values()].sort(
|
||||||
|
(left, right) => left.expiresAt - right.expiresAt
|
||||||
|
)[0];
|
||||||
|
if (!oldest) break;
|
||||||
|
state.pendingUnknownSessionIds.delete(oldest.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prunePendingUnknownSessions(state: TrackingState): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, candidate] of state.pendingUnknownSessionIds.entries()) {
|
||||||
|
if (
|
||||||
|
candidate.expiresAt <= now ||
|
||||||
|
candidate.refreshAttempts >= MAX_PENDING_UNKNOWN_ROOT_REFRESH_ATTEMPTS
|
||||||
|
) {
|
||||||
|
state.pendingUnknownSessionIds.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markPendingRefreshAttempt(state: TrackingState): void {
|
||||||
|
for (const candidate of state.pendingUnknownSessionIds.values()) {
|
||||||
|
candidate.refreshAttempts += 1;
|
||||||
|
}
|
||||||
|
this.prunePendingUnknownSessions(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeConfirmedPendingSessions(
|
||||||
|
state: TrackingState,
|
||||||
|
confirmedSessionIds: readonly string[]
|
||||||
|
): void {
|
||||||
|
for (const rawSessionId of confirmedSessionIds) {
|
||||||
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
|
if (sessionId) {
|
||||||
|
state.pendingUnknownSessionIds.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleScopedRecompute(teamName: string): void {
|
||||||
|
const current = this.stateByTeam.get(teamName);
|
||||||
|
if (!current || this.getActiveConsumerCount(current) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current.refreshTimer) {
|
||||||
|
clearTimeout(current.refreshTimer);
|
||||||
|
}
|
||||||
|
current.refreshTimer = setTimeout(() => {
|
||||||
|
current.refreshTimer = null;
|
||||||
|
void this.recompute(teamName);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleContextRefresh(
|
||||||
|
teamName: string,
|
||||||
|
candidateSessionId?: string,
|
||||||
|
delayMs: number = CONTEXT_REFRESH_DEBOUNCE_MS
|
||||||
|
): void {
|
||||||
|
const state = this.stateByTeam.get(teamName);
|
||||||
|
if (!state || this.getActiveConsumerCount(state) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.rememberPendingUnknownSession(state, candidateSessionId);
|
||||||
|
if (state.contextRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.contextRefreshTimer = setTimeout(() => {
|
||||||
|
const current = this.stateByTeam.get(teamName);
|
||||||
|
if (!current) return;
|
||||||
|
current.contextRefreshTimer = null;
|
||||||
|
if (this.getActiveConsumerCount(current) === 0) return;
|
||||||
|
void this.refreshContextAndWatcher(teamName, current.lifecycleVersion);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshContextAndWatcher(teamName: string, expectedVersion: number): Promise<void> {
|
||||||
|
const state = this.stateByTeam.get(teamName);
|
||||||
|
if (!state || !this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.markPendingRefreshAttempt(state);
|
||||||
|
|
||||||
|
const previousGeneration = state.snapshot.logSourceGeneration;
|
||||||
|
const context = await this.logsFinder.getLiveLogSourceWatchContext(teamName, {
|
||||||
|
forceRefresh: true,
|
||||||
|
});
|
||||||
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activeContext = context;
|
||||||
|
if (!context) {
|
||||||
|
state.scopedSessionIds.clear();
|
||||||
|
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||||
|
await this.rebuildWatcher(teamName, null, expectedVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeConfirmedPendingSessions(state, context.watchSessionIds);
|
||||||
|
state.snapshot = await this.computeSnapshot(context);
|
||||||
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.rebuildWatcher(teamName, context, expectedVersion);
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.snapshot.logSourceGeneration &&
|
||||||
|
previousGeneration !== state.snapshot.logSourceGeneration
|
||||||
|
) {
|
||||||
|
this.emitLogSourceChange(teamName);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.isTrackingCurrent(teamName, expectedVersion) &&
|
||||||
|
state.pendingUnknownSessionIds.size > 0
|
||||||
|
) {
|
||||||
|
this.scheduleContextRefresh(teamName, undefined, PENDING_CONTEXT_REFRESH_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleTaskFreshnessSignalChange(
|
private handleTaskFreshnessSignalChange(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
|
|
@ -440,22 +724,15 @@ export class TeamLogSourceTracker {
|
||||||
const recomputeVersion = state.lifecycleVersion;
|
const recomputeVersion = state.lifecycleVersion;
|
||||||
const recomputePromise = (async () => {
|
const recomputePromise = (async () => {
|
||||||
const previousGeneration = state.snapshot.logSourceGeneration;
|
const previousGeneration = state.snapshot.logSourceGeneration;
|
||||||
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
|
const context = state.activeContext;
|
||||||
forceRefresh: true,
|
|
||||||
});
|
|
||||||
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
|
|
||||||
return this.getOrCreateState(teamName).snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||||
await this.rebuildWatcher(teamName, null, recomputeVersion);
|
|
||||||
} else {
|
} else {
|
||||||
state.snapshot = await this.computeSnapshot(context);
|
state.snapshot = await this.computeSnapshot(context);
|
||||||
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
|
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
|
||||||
return this.getOrCreateState(teamName).snapshot;
|
return this.getOrCreateState(teamName).snapshot;
|
||||||
}
|
}
|
||||||
await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -495,23 +772,21 @@ export class TeamLogSourceTracker {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeSnapshot(context: {
|
private async computeSnapshot(context: TeamLogSourceLiveContext): Promise<TeamLogSourceSnapshot> {
|
||||||
projectDir: string;
|
|
||||||
projectPath?: string;
|
|
||||||
leadSessionId?: string;
|
|
||||||
sessionIds: string[];
|
|
||||||
}): Promise<TeamLogSourceSnapshot> {
|
|
||||||
const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath);
|
const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
const sessionIds =
|
||||||
|
context.watchSessionIds.length > 0 ? context.watchSessionIds : context.sessionIds;
|
||||||
|
|
||||||
if (context.leadSessionId) {
|
for (const rawSessionId of [...sessionIds].sort((a, b) => a.localeCompare(b))) {
|
||||||
const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`);
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
parts.push(await this.describePath('lead', leadLogPath));
|
if (!sessionId) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) {
|
const rootLogPath = path.join(context.projectDir, `${sessionId}.jsonl`);
|
||||||
const sessionDir = path.join(context.projectDir, sessionId);
|
const sessionDir = path.join(context.projectDir, sessionId);
|
||||||
const subagentsDir = path.join(sessionDir, 'subagents');
|
const subagentsDir = path.join(sessionDir, 'subagents');
|
||||||
|
parts.push(await this.describePath('root', rootLogPath));
|
||||||
parts.push(await this.describePath('session', sessionDir));
|
parts.push(await this.describePath('session', sessionDir));
|
||||||
parts.push(await this.describePath('subagents', subagentsDir));
|
parts.push(await this.describePath('subagents', subagentsDir));
|
||||||
|
|
||||||
|
|
@ -523,25 +798,19 @@ export class TeamLogSourceTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fileName of entries
|
for (const fileName of entries
|
||||||
.filter(
|
.filter((entry) => isAgentTranscriptFileName(entry))
|
||||||
(entry) =>
|
|
||||||
entry.startsWith('agent-') &&
|
|
||||||
entry.endsWith('.jsonl') &&
|
|
||||||
!entry.startsWith('agent-acompact')
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.localeCompare(b))) {
|
.sort((a, b) => a.localeCompare(b))) {
|
||||||
parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName)));
|
parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceMaterial =
|
if (parts.length === 0) {
|
||||||
parts.length > 0
|
return { projectFingerprint, logSourceGeneration: null };
|
||||||
? parts.join('|')
|
}
|
||||||
: `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectFingerprint,
|
projectFingerprint,
|
||||||
logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'),
|
logSourceGeneration: createHash('sha256').update(parts.join('|')).digest('hex'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,16 @@ import {
|
||||||
canonicalizeAgentTeamsToolName,
|
canonicalizeAgentTeamsToolName,
|
||||||
lineHasAgentTeamsTaskBoundaryToolName,
|
lineHasAgentTeamsTaskBoundaryToolName,
|
||||||
} from './agentTeamsToolNames';
|
} from './agentTeamsToolNames';
|
||||||
|
import {
|
||||||
|
readBootstrapLaunchSnapshot,
|
||||||
|
choosePreferredLaunchSnapshot,
|
||||||
|
} from './TeamBootstrapStateReader';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
import { buildTeamLogWatchSessionIds, extractRuntimeSessionIds } from './teamLogSourceWatchScope';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
|
@ -107,6 +113,14 @@ export interface MemberLogFileRef {
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamLogSourceLiveContext {
|
||||||
|
projectDir: string;
|
||||||
|
projectPath?: string;
|
||||||
|
leadSessionId?: string;
|
||||||
|
sessionIds: string[];
|
||||||
|
watchSessionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
async function mapLimit<T, R>(
|
async function mapLimit<T, R>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
limit: number,
|
limit: number,
|
||||||
|
|
@ -151,7 +165,8 @@ export class TeamMemberLogsFinder {
|
||||||
{
|
{
|
||||||
getConfig: (teamName) => configReader.getConfigSnapshot(teamName),
|
getConfig: (teamName) => configReader.getConfigSnapshot(teamName),
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findMemberLogs(
|
async findMemberLogs(
|
||||||
|
|
@ -262,6 +277,43 @@ export class TeamMemberLogsFinder {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLiveLogSourceWatchContext(
|
||||||
|
teamName: string,
|
||||||
|
options?: { forceRefresh?: boolean }
|
||||||
|
): Promise<TeamLogSourceLiveContext | null> {
|
||||||
|
const [launchSnapshot, bootstrapSnapshot] = await Promise.all([
|
||||||
|
this.launchStateStore.read(teamName).catch(() => null),
|
||||||
|
readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
||||||
|
]);
|
||||||
|
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||||
|
const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
|
||||||
|
(member) => member.cwd
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await this.projectResolver.getLiveBaseContext(teamName, {
|
||||||
|
forceRefresh: options?.forceRefresh,
|
||||||
|
extraProjectPathCandidates,
|
||||||
|
});
|
||||||
|
if (!base) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchSessionIds = buildTeamLogWatchSessionIds({
|
||||||
|
configLeadSessionId: base.config.leadSessionId,
|
||||||
|
launchLeadSessionId: preferredSnapshot?.leadSessionId,
|
||||||
|
sessionHistory: base.config.sessionHistory,
|
||||||
|
launchRuntimeSessionIds: extractRuntimeSessionIds(preferredSnapshot),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectDir: base.projectDir,
|
||||||
|
projectPath: base.config.projectPath,
|
||||||
|
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
|
||||||
|
sessionIds: watchSessionIds,
|
||||||
|
watchSessionIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
|
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
|
||||||
* When the task is in_progress and has an owner, also includes that owner's session logs so
|
* When the task is in_progress and has an owner, also includes that owner's session logs so
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,12 @@ export interface TeamTranscriptProjectContext {
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamTranscriptProjectLiveBaseContext {
|
||||||
|
projectDir: string;
|
||||||
|
projectId: string;
|
||||||
|
config: TeamConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamTranscriptProjectResolver {
|
export class TeamTranscriptProjectResolver {
|
||||||
private readonly contextCache = new Map<
|
private readonly contextCache = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -201,6 +207,41 @@ export class TeamTranscriptProjectResolver {
|
||||||
: this.configReader.getConfig(teamName);
|
: this.configReader.getConfig(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLiveBaseContext(
|
||||||
|
teamName: string,
|
||||||
|
options?: { forceRefresh?: boolean; extraProjectPathCandidates?: readonly unknown[] }
|
||||||
|
): Promise<TeamTranscriptProjectLiveBaseContext | null> {
|
||||||
|
if (options?.forceRefresh) {
|
||||||
|
this.contextCache.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.readConfigForObservation(teamName);
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPathCandidates = this.collectLiveProjectPathCandidates(
|
||||||
|
config,
|
||||||
|
options?.extraProjectPathCandidates ?? []
|
||||||
|
);
|
||||||
|
const resolution = await this.resolveLiveProjectDirectoryFromCandidates(projectPathCandidates);
|
||||||
|
if (!resolution) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedConfig =
|
||||||
|
trimTrailingSlashes(config.projectPath ?? '') !==
|
||||||
|
trimTrailingSlashes(resolution.effectiveProjectPath)
|
||||||
|
? { ...config, projectPath: resolution.effectiveProjectPath }
|
||||||
|
: config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectDir: resolution.projectDir,
|
||||||
|
projectId: resolution.projectId,
|
||||||
|
config: resolvedConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getContext(
|
async getContext(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { forceRefresh?: boolean }
|
options?: { forceRefresh?: boolean }
|
||||||
|
|
@ -438,6 +479,46 @@ export class TeamTranscriptProjectResolver {
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectLiveProjectPathCandidates(
|
||||||
|
config: TeamConfig,
|
||||||
|
extraCandidates: readonly unknown[]
|
||||||
|
): string[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (value: unknown): void => {
|
||||||
|
const normalized = normalizeProjectPathCandidate(value);
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
candidates.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(config.projectPath);
|
||||||
|
|
||||||
|
const history = Array.isArray(config.projectPathHistory) ? config.projectPathHistory : [];
|
||||||
|
for (let index = history.length - 1; index >= Math.max(0, history.length - 5); index -= 1) {
|
||||||
|
push(history[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
push((config.members ?? []).find((member) => isLeadMember(member))?.cwd);
|
||||||
|
|
||||||
|
const distinctMemberCwds = new Set(
|
||||||
|
(config.members ?? [])
|
||||||
|
.map((member) => normalizeProjectPathCandidate(member.cwd))
|
||||||
|
.filter((cwd): cwd is string => Boolean(cwd))
|
||||||
|
);
|
||||||
|
if (distinctMemberCwds.size === 1) {
|
||||||
|
push([...distinctMemberCwds][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of extraCandidates.slice(0, 64)) {
|
||||||
|
push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
|
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
|
||||||
const normalizedProjectPath = trimTrailingSlashes(projectPath);
|
const normalizedProjectPath = trimTrailingSlashes(projectPath);
|
||||||
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
|
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
|
||||||
|
|
@ -470,6 +551,32 @@ export class TeamTranscriptProjectResolver {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveLiveProjectDirectoryFromCandidates(
|
||||||
|
candidates: readonly string[]
|
||||||
|
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath: string } | null> {
|
||||||
|
let firstResolution: {
|
||||||
|
projectDir: string;
|
||||||
|
projectId: string;
|
||||||
|
effectiveProjectPath: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
for (const projectPath of candidates) {
|
||||||
|
for (const dirCandidate of this.buildProjectDirCandidates(projectPath)) {
|
||||||
|
const resolution = {
|
||||||
|
projectDir: dirCandidate.projectDir,
|
||||||
|
projectId: dirCandidate.projectId,
|
||||||
|
effectiveProjectPath: projectPath,
|
||||||
|
};
|
||||||
|
firstResolution ??= resolution;
|
||||||
|
if (await this.projectDirExists(dirCandidate.projectDir)) {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async findMatchInProjectPathCandidate(
|
private async findMatchInProjectPathCandidate(
|
||||||
candidate: ProjectPathCandidate,
|
candidate: ProjectPathCandidate,
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
|
|
|
||||||
178
src/main/services/team/teamLogSourceWatchScope.ts
Normal file
178
src/main/services/team/teamLogSourceWatchScope.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
export const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
||||||
|
export const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
|
||||||
|
export const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
|
||||||
|
export const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
||||||
|
export const MAX_LOG_SOURCE_WATCH_SESSION_IDS = 24;
|
||||||
|
export const MAX_PENDING_UNKNOWN_ROOT_SESSIONS = 16;
|
||||||
|
export const PENDING_UNKNOWN_ROOT_SESSION_TTL_MS = 15_000;
|
||||||
|
export const MAX_PENDING_UNKNOWN_ROOT_REFRESH_ATTEMPTS = 4;
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
||||||
|
const AGENT_TRANSCRIPT_RE = /^agent-(?!acompact).*\.jsonl$/;
|
||||||
|
|
||||||
|
export type WatcherEventName = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir';
|
||||||
|
|
||||||
|
export type LogSourceWatcherAction =
|
||||||
|
| { kind: 'ignore' }
|
||||||
|
| { kind: 'task-freshness' }
|
||||||
|
| { kind: 'scoped-recompute' }
|
||||||
|
| { kind: 'context-refresh'; candidateSessionId?: string };
|
||||||
|
|
||||||
|
export interface TeamLogWatchSessionInput {
|
||||||
|
configLeadSessionId?: unknown;
|
||||||
|
launchLeadSessionId?: unknown;
|
||||||
|
sessionHistory?: unknown[];
|
||||||
|
launchRuntimeSessionIds?: unknown[];
|
||||||
|
bootstrapRuntimeSessionIds?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassifyLogSourceWatcherEventInput {
|
||||||
|
projectDir: string;
|
||||||
|
changedPath: string;
|
||||||
|
eventName: WatcherEventName;
|
||||||
|
scopedSessionIds: ReadonlySet<string>;
|
||||||
|
pendingUnknownSessionIds: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogSourceSessionId(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || !SAFE_SESSION_ID_RE.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSessionId(ids: string[], seen: Set<string>, value: unknown, limit: number): void {
|
||||||
|
if (ids.length >= limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeLogSourceSessionId(value);
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
ids.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeRuntimeMember(member: PersistedTeamLaunchMemberState): boolean {
|
||||||
|
return Boolean(
|
||||||
|
member.runtimeSessionId &&
|
||||||
|
!member.hardFailure &&
|
||||||
|
member.launchState !== 'skipped_for_launch' &&
|
||||||
|
member.launchState !== 'failed_to_start'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRuntimeSessionIds(
|
||||||
|
snapshot: PersistedTeamLaunchSnapshot | null | undefined
|
||||||
|
): string[] {
|
||||||
|
if (!snapshot?.members) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const member of Object.values(snapshot.members)) {
|
||||||
|
if (!shouldIncludeRuntimeMember(member)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushSessionId(ids, seen, member.runtimeSessionId, MAX_LOG_SOURCE_WATCH_SESSION_IDS);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTeamLogWatchSessionIds(
|
||||||
|
input: TeamLogWatchSessionInput,
|
||||||
|
limit = MAX_LOG_SOURCE_WATCH_SESSION_IDS
|
||||||
|
): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
pushSessionId(ids, seen, input.configLeadSessionId, limit);
|
||||||
|
pushSessionId(ids, seen, input.launchLeadSessionId, limit);
|
||||||
|
|
||||||
|
for (const sessionId of input.launchRuntimeSessionIds ?? []) {
|
||||||
|
pushSessionId(ids, seen, sessionId, limit);
|
||||||
|
}
|
||||||
|
for (const sessionId of input.bootstrapRuntimeSessionIds ?? []) {
|
||||||
|
pushSessionId(ids, seen, sessionId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = Array.isArray(input.sessionHistory) ? input.sessionHistory : [];
|
||||||
|
for (let index = history.length - 1; index >= 0; index -= 1) {
|
||||||
|
pushSessionId(ids, seen, history[index], limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentTranscriptFileName(fileName: string): boolean {
|
||||||
|
return AGENT_TRANSCRIPT_RE.test(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelativeLogSourceParts(projectDir: string, targetPath: string): string[] | null {
|
||||||
|
const relativePath = path.relative(projectDir, targetPath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return relativePath.split(/[/\\]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyLogSourceWatcherEvent(
|
||||||
|
input: ClassifyLogSourceWatcherEventInput
|
||||||
|
): LogSourceWatcherAction {
|
||||||
|
const parts = getRelativeLogSourceParts(input.projectDir, input.changedPath);
|
||||||
|
if (!parts) {
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = parts[0];
|
||||||
|
if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME || first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) {
|
||||||
|
return { kind: 'task-freshness' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
first === BOARD_TASK_CHANGES_DIRNAME ||
|
||||||
|
parts.includes('tool-results') ||
|
||||||
|
parts.includes('memory')
|
||||||
|
) {
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1 && first.endsWith('.jsonl')) {
|
||||||
|
const sessionId = normalizeLogSourceSessionId(first.slice(0, -'.jsonl'.length));
|
||||||
|
if (!sessionId) {
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
if (input.scopedSessionIds.has(sessionId)) {
|
||||||
|
return { kind: 'scoped-recompute' };
|
||||||
|
}
|
||||||
|
if (input.eventName === 'add' || input.pendingUnknownSessionIds.has(sessionId)) {
|
||||||
|
return { kind: 'context-refresh', candidateSessionId: sessionId };
|
||||||
|
}
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.scopedSessionIds.has(first)) {
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { kind: 'scoped-recompute' };
|
||||||
|
}
|
||||||
|
if (parts[1] === 'subagents') {
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return { kind: 'context-refresh' };
|
||||||
|
}
|
||||||
|
if (parts.length === 3 && isAgentTranscriptFileName(parts[2])) {
|
||||||
|
return { kind: 'scoped-recompute' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
|
@ -30,10 +30,11 @@ describe('TeamLogSourceTracker', () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
|
@ -66,10 +67,11 @@ describe('TeamLogSourceTracker', () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
|
@ -104,14 +106,109 @@ describe('TeamLogSourceTracker', () => {
|
||||||
expect(emitter).not.toHaveBeenCalled();
|
expect(emitter).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits log-source-change for scoped root transcripts', async () => {
|
||||||
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-scoped-root-'));
|
||||||
|
await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n');
|
||||||
|
|
||||||
|
const logsFinder = {
|
||||||
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
projectDir: tempDir!,
|
||||||
|
sessionIds: ['lead-session'],
|
||||||
|
watchSessionIds: ['lead-session'],
|
||||||
|
})),
|
||||||
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||||
|
tracker.setEmitter(emitter);
|
||||||
|
|
||||||
|
await tracker.enableTracking('demo', 'change_presence');
|
||||||
|
emitter.mockClear();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":2}\n');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(emitter).toHaveBeenCalledWith({
|
||||||
|
type: 'log-source-change',
|
||||||
|
teamName: 'demo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.disableTracking('demo', 'change_presence');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores old unscoped root transcript changes', async () => {
|
||||||
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-unscoped-root-'));
|
||||||
|
await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n');
|
||||||
|
await writeFile(path.join(tempDir, 'old-session.jsonl'), '{"seq":1}\n');
|
||||||
|
|
||||||
|
const logsFinder = {
|
||||||
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
projectDir: tempDir!,
|
||||||
|
sessionIds: ['lead-session'],
|
||||||
|
watchSessionIds: ['lead-session'],
|
||||||
|
})),
|
||||||
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||||
|
tracker.setEmitter(emitter);
|
||||||
|
|
||||||
|
await tracker.enableTracking('demo', 'change_presence');
|
||||||
|
emitter.mockClear();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
await writeFile(path.join(tempDir, 'old-session.jsonl'), '{"seq":2}\n');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||||
|
|
||||||
|
expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change');
|
||||||
|
|
||||||
|
await tracker.disableTracking('demo', 'change_presence');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits log-source-change when a pending root transcript becomes confirmed', async () => {
|
||||||
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-pending-root-'));
|
||||||
|
let confirmed = false;
|
||||||
|
|
||||||
|
const logsFinder = {
|
||||||
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
projectDir: tempDir!,
|
||||||
|
sessionIds: confirmed ? ['new-runtime'] : [],
|
||||||
|
watchSessionIds: confirmed ? ['new-runtime'] : [],
|
||||||
|
})),
|
||||||
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||||
|
tracker.setEmitter(emitter);
|
||||||
|
|
||||||
|
await tracker.enableTracking('demo', 'change_presence');
|
||||||
|
emitter.mockClear();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
confirmed = true;
|
||||||
|
await writeFile(path.join(tempDir, 'new-runtime.jsonl'), '{"seq":1}\n');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(emitter).toHaveBeenCalledWith({
|
||||||
|
type: 'log-source-change',
|
||||||
|
teamName: 'demo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await tracker.disableTracking('demo', 'change_presence');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not reinitialize when another consumer joins an already tracked team', async () => {
|
it('does not reinitialize when another consumer joins an already tracked team', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
|
@ -119,7 +216,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await tracker.enableTracking('demo', 'tool_activity');
|
await tracker.enableTracking('demo', 'tool_activity');
|
||||||
await tracker.enableTracking('demo', 'task_log_stream');
|
await tracker.enableTracking('demo', 'task_log_stream');
|
||||||
|
|
||||||
expect(logsFinder.getLogSourceWatchContext).toHaveBeenCalledTimes(1);
|
expect(logsFinder.getLiveLogSourceWatchContext).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await tracker.disableTracking('demo', 'task_log_stream');
|
await tracker.disableTracking('demo', 'task_log_stream');
|
||||||
await tracker.disableTracking('demo', 'tool_activity');
|
await tracker.disableTracking('demo', 'tool_activity');
|
||||||
|
|
@ -127,10 +224,11 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('notifies log-source listeners before forwarding the external team change event', () => {
|
it('notifies log-source listeners before forwarding the external team change event', () => {
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: '/tmp/demo',
|
projectDir: '/tmp/demo',
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
|
@ -154,10 +252,11 @@ describe('TeamLogSourceTracker', () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
|
@ -188,10 +287,11 @@ describe('TeamLogSourceTracker', () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
projectDir: tempDir!,
|
projectDir: tempDir!,
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
})),
|
watchSessionIds: [],
|
||||||
|
})),
|
||||||
} as unknown as TeamMemberLogsFinder;
|
} as unknown as TeamMemberLogsFinder;
|
||||||
|
|
||||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
|
|
@ -18,6 +18,82 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds live log source context without broad transcript discovery', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-live-context-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
||||||
|
const teamName = 'live-context-team';
|
||||||
|
const projectPath = '/Users/test/live-context';
|
||||||
|
const projectRoot = path.join(tmpDir, 'projects', '-Users-test-live-context');
|
||||||
|
const config = {
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
leadSessionId: 'lead-session',
|
||||||
|
sessionHistory: ['old-session', 'recent-session'],
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
await fs.mkdir(projectRoot, { recursive: true });
|
||||||
|
|
||||||
|
const projectResolver = {
|
||||||
|
getLiveBaseContext: vi.fn(async () => ({
|
||||||
|
projectDir: projectRoot,
|
||||||
|
projectId: '-Users-test-live-context',
|
||||||
|
config,
|
||||||
|
})),
|
||||||
|
getContext: vi.fn(async () => {
|
||||||
|
throw new Error('broad context must not be used for live tracking');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const launchStateStore = {
|
||||||
|
read: vi.fn(async () => ({
|
||||||
|
version: 2,
|
||||||
|
teamName,
|
||||||
|
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||||
|
leadSessionId: 'lead-session',
|
||||||
|
launchPhase: 'active',
|
||||||
|
expectedMembers: ['bob'],
|
||||||
|
members: {
|
||||||
|
bob: {
|
||||||
|
name: 'bob',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
runtimeSessionId: 'runtime-bob',
|
||||||
|
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: {},
|
||||||
|
teamLaunchState: 'partial_pending',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const finder = new TeamMemberLogsFinder(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
projectResolver as never,
|
||||||
|
launchStateStore as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = await finder.getLiveLogSourceWatchContext(teamName, { forceRefresh: true });
|
||||||
|
|
||||||
|
expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
expect.objectContaining({ forceRefresh: true })
|
||||||
|
);
|
||||||
|
expect(projectResolver.getContext).not.toHaveBeenCalled();
|
||||||
|
expect(context?.projectDir).toBe(projectRoot);
|
||||||
|
expect(context?.watchSessionIds).toEqual([
|
||||||
|
'lead-session',
|
||||||
|
'runtime-bob',
|
||||||
|
'recent-session',
|
||||||
|
'old-session',
|
||||||
|
]);
|
||||||
|
expect(context?.sessionIds).toEqual(context?.watchSessionIds);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns subagent logs for a member and lead session for team-lead', async () => {
|
it('returns subagent logs for a member and lead session for team-lead', 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);
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,35 @@ describe('TeamTranscriptProjectResolver', () => {
|
||||||
expect(getConfig).not.toHaveBeenCalled();
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves live base context from cheap projectPath evidence without session discovery', async () => {
|
||||||
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
const teamName = 'live-base-team';
|
||||||
|
const projectPath = '/Users/test/live-base';
|
||||||
|
const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath));
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
const getConfig = vi.fn(async () => {
|
||||||
|
throw new Error('verified config read should not be used for live base context');
|
||||||
|
});
|
||||||
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
leadSessionId: 'lead-session',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
}));
|
||||||
|
const resolver = new TeamTranscriptProjectResolver({
|
||||||
|
getConfig,
|
||||||
|
getConfigSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await resolver.getLiveBaseContext(teamName, { forceRefresh: true });
|
||||||
|
|
||||||
|
expect(context?.projectDir).toBe(projectDir);
|
||||||
|
expect(context?.config.projectPath).toBe(projectPath);
|
||||||
|
expect(getConfigSnapshot).toHaveBeenCalledWith(teamName);
|
||||||
|
expect(getConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
||||||
await setupClaudeRoot();
|
await setupClaudeRoot();
|
||||||
|
|
||||||
|
|
|
||||||
94
test/main/services/team/teamLogSourceWatchScope.test.ts
Normal file
94
test/main/services/team/teamLogSourceWatchScope.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import * as path from 'path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTeamLogWatchSessionIds,
|
||||||
|
classifyLogSourceWatcherEvent,
|
||||||
|
extractRuntimeSessionIds,
|
||||||
|
normalizeLogSourceSessionId,
|
||||||
|
} from '../../../../src/main/services/team/teamLogSourceWatchScope';
|
||||||
|
|
||||||
|
describe('teamLogSourceWatchScope', () => {
|
||||||
|
it('builds a bounded confirmed session scope with lead, runtime ids, and newest history first', () => {
|
||||||
|
const ids = buildTeamLogWatchSessionIds({
|
||||||
|
configLeadSessionId: 'lead-session',
|
||||||
|
launchLeadSessionId: 'lead-session',
|
||||||
|
launchRuntimeSessionIds: ['runtime-bob'],
|
||||||
|
sessionHistory: ['old-session', 'recent-session', 'lead-session'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ids).toEqual(['lead-session', 'runtime-bob', 'recent-session', 'old-session']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes session ids defensively', () => {
|
||||||
|
expect(normalizeLogSourceSessionId(' valid_id-1 ')).toBe('valid_id-1');
|
||||||
|
expect(normalizeLogSourceSessionId('')).toBeNull();
|
||||||
|
expect(normalizeLogSourceSessionId('../escape')).toBeNull();
|
||||||
|
expect(normalizeLogSourceSessionId('with/slash')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts runtime session ids from launch members that can still write early logs', () => {
|
||||||
|
const ids = extractRuntimeSessionIds({
|
||||||
|
members: {
|
||||||
|
pending: {
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
runtimeSessionId: 'runtime-pending',
|
||||||
|
hardFailure: false,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeSessionId: 'runtime-failed',
|
||||||
|
hardFailure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(ids).toEqual(['runtime-pending']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies unknown, pending, scoped, and ignored watcher events', () => {
|
||||||
|
const projectDir = '/tmp/project';
|
||||||
|
const scopedSessionIds = new Set(['lead-session']);
|
||||||
|
const pendingUnknownSessionIds = new Set(['new-runtime']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyLogSourceWatcherEvent({
|
||||||
|
projectDir,
|
||||||
|
changedPath: path.join(projectDir, 'old-session.jsonl'),
|
||||||
|
eventName: 'change',
|
||||||
|
scopedSessionIds,
|
||||||
|
pendingUnknownSessionIds,
|
||||||
|
})
|
||||||
|
).toEqual({ kind: 'ignore' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyLogSourceWatcherEvent({
|
||||||
|
projectDir,
|
||||||
|
changedPath: path.join(projectDir, 'new-runtime.jsonl'),
|
||||||
|
eventName: 'change',
|
||||||
|
scopedSessionIds,
|
||||||
|
pendingUnknownSessionIds,
|
||||||
|
})
|
||||||
|
).toEqual({ kind: 'context-refresh', candidateSessionId: 'new-runtime' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyLogSourceWatcherEvent({
|
||||||
|
projectDir,
|
||||||
|
changedPath: path.join(projectDir, 'lead-session', 'subagents', 'agent-worker.jsonl'),
|
||||||
|
eventName: 'change',
|
||||||
|
scopedSessionIds,
|
||||||
|
pendingUnknownSessionIds,
|
||||||
|
})
|
||||||
|
).toEqual({ kind: 'scoped-recompute' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyLogSourceWatcherEvent({
|
||||||
|
projectDir,
|
||||||
|
changedPath: path.join(projectDir, 'lead-session', 'subagents', 'agent-acompact-x.jsonl'),
|
||||||
|
eventName: 'change',
|
||||||
|
scopedSessionIds,
|
||||||
|
pendingUnknownSessionIds,
|
||||||
|
})
|
||||||
|
).toEqual({ kind: 'ignore' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue