agent-ecosystem/src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.ts

173 lines
5.4 KiB
TypeScript

import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActivityParseCache';
import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const TEAM_TASK_LOG_FRESHNESS_DIRNAME = 'task-log-freshness';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
interface ParsedFreshnessSignal {
taskId: string;
updatedAt: string;
transcriptFileBasename?: string;
}
function isWindowsReservedArtifactSegment(segment: string): boolean {
const stem = segment.split('.')[0]?.toUpperCase() ?? '';
return (
!segment ||
stem === 'CON' ||
stem === 'PRN' ||
stem === 'AUX' ||
stem === 'NUL' ||
/^COM[1-9]$/.test(stem) ||
/^LPT[1-9]$/.test(stem)
);
}
function encodeTaskId(taskId: string): string {
const encoded = encodeURIComponent(taskId);
return isWindowsReservedArtifactSegment(encoded) ||
encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH
? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`
: encoded;
}
function taskIdArtifactSegments(taskId: string): string[] {
const safe = encodeTaskId(taskId);
const legacy = encodeURIComponent(taskId);
return safe === legacy ? [safe] : [safe, legacy];
}
function taskSignalPathCandidates(projectDir: string, taskId: string, teamName?: string): string[] {
const dirs = [
...(teamName ? [path.join(getTeamsBasePath(), teamName, TEAM_TASK_LOG_FRESHNESS_DIRNAME)] : []),
path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME),
];
return dirs.flatMap((dir) =>
taskIdArtifactSegments(taskId).map((segment) =>
path.join(dir, `${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`)
)
);
}
function isValidTimestamp(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
}
export class TeamTaskLogFreshnessReader {
private readonly cache = new BoardTaskActivityParseCache<ParsedFreshnessSignal | false>();
async readSignals(
projectDir: string,
taskIds: string[],
options?: { teamName?: string }
): Promise<Map<string, TaskLogFreshnessSignal>> {
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
const signalFilePathCandidates = uniqueTaskIds.map((taskId) =>
taskSignalPathCandidates(projectDir, taskId, options?.teamName)
);
this.cache.retainOnly(new Set(signalFilePathCandidates.flat()));
const rows = await Promise.all(
uniqueTaskIds.map(async (taskId, index) => {
const candidates = signalFilePathCandidates[index] ?? [];
const result = await this.readFirstSignal(candidates);
if (result?.parsed.taskId !== taskId) {
return null;
}
const parsed = result.parsed;
return [
taskId,
{
taskId,
updatedAt: parsed.updatedAt,
filePath: result.filePath,
...(parsed.transcriptFileBasename
? { transcriptFileBasename: parsed.transcriptFileBasename }
: {}),
} satisfies TaskLogFreshnessSignal,
] as const;
})
);
return new Map(rows.filter((row): row is NonNullable<typeof row> => row !== null));
}
private async readFirstSignal(
filePaths: string[]
): Promise<{ filePath: string; parsed: ParsedFreshnessSignal } | null> {
for (const filePath of filePaths) {
const parsed = await this.readSignal(filePath);
if (parsed) {
return { filePath, parsed };
}
}
return null;
}
private async readSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
this.cache.clearForPath(filePath);
return false;
}
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached !== null) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.parseSignal(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch {
this.cache.clearForPath(filePath);
return false;
}
}
private async parseSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return false;
}
const record = parsed as Record<string, unknown>;
const taskId =
typeof record.taskId === 'string' && record.taskId.trim().length > 0
? record.taskId.trim()
: null;
const updatedAt = isValidTimestamp(record.updatedAt) ? record.updatedAt : null;
if (!taskId || !updatedAt) {
return false;
}
return {
taskId,
updatedAt,
...(typeof record.transcriptFile === 'string' && record.transcriptFile.trim().length > 0
? { transcriptFileBasename: path.basename(record.transcriptFile.trim()) }
: {}),
};
}
}