Force-directed graph visualization for agent teams. Package: @claude-teams/agent-graph (isolated workspace package) - Space theme: bloom, particles, hex grid, depth stars - Members as hexagonal nodes with breathing glow - Tasks as pill cards in kanban columns (todo/wip/done/review/approved) per owner - Message particles along edges (real-time only) - Deterministic layout, Figma-style pan, scroll/pinch zoom - Clean Architecture: ports/adapters/strategies, ES #private classes Integration: features/agent-graph/ (adapter + overlay + tab) - Full-screen overlay (Cmd+Shift+G) + Pin as Tab - Graph button in Team section header - Frustum culling, zero per-frame allocations, adaptive fps - Performance overlay via ?perf query param Also: CI runs on all PR branches, features/CLAUDE.md architecture guide
754 lines
26 KiB
TypeScript
754 lines
26 KiB
TypeScript
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import {
|
|
getTaskChangeStateBucket,
|
|
isTaskChangeSummaryCacheable,
|
|
type TaskChangeStateBucket,
|
|
} from '@shared/utils/taskChangeState';
|
|
import { createHash } from 'crypto';
|
|
import { readFile, stat } from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
|
|
import { TaskChangeComputer } from './TaskChangeComputer';
|
|
import {
|
|
buildTaskChangePresenceDescriptor,
|
|
computeTaskChangePresenceProjectFingerprint,
|
|
normalizeTaskChangePresenceFilePath,
|
|
} from './taskChangePresenceUtils';
|
|
import { getTaskChangeWorkerClient } from './TaskChangeWorkerClient';
|
|
import {
|
|
type ResolvedTaskChangeComputeInput,
|
|
type TaskChangeEffectiveOptions,
|
|
type TaskChangeTaskMeta,
|
|
} from './taskChangeWorkerTypes';
|
|
import { TeamConfigReader } from './TeamConfigReader';
|
|
|
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
|
import type { TaskBoundaryParser } from './TaskBoundaryParser';
|
|
import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
|
|
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
|
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
|
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
|
|
|
|
const logger = createLogger('Service:ChangeExtractorService');
|
|
|
|
/** Кеш-запись: данные + mtime файла + время протухания */
|
|
interface CacheEntry {
|
|
data: AgentChangeSet;
|
|
mtime: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface TaskChangeSummaryCacheEntry {
|
|
data: TaskChangeSetV2;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface LogFileRef {
|
|
filePath: string;
|
|
memberName: string;
|
|
}
|
|
|
|
export class ChangeExtractorService {
|
|
private cache = new Map<string, CacheEntry>();
|
|
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
|
|
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
|
|
private taskChangeSummaryVersionByTask = new Map<string, number>();
|
|
private taskChangeSummaryValidationInFlight = new Set<string>();
|
|
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
|
private readonly taskChangeSummaryCacheTtl = 60 * 1000;
|
|
private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000;
|
|
private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000;
|
|
private readonly maxTaskChangeSummaryCacheEntries = 200;
|
|
private readonly isPersistedTaskChangeCacheEnabled =
|
|
process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0';
|
|
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
|
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
|
private readonly taskChangeComputer: TaskChangeComputer;
|
|
|
|
constructor(
|
|
private readonly logsFinder: TeamMemberLogsFinder,
|
|
boundaryParser: TaskBoundaryParser,
|
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
|
private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(),
|
|
private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient()
|
|
) {
|
|
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
|
|
}
|
|
|
|
setTaskChangePresenceServices(
|
|
repository: TaskChangePresenceRepository,
|
|
tracker: TeamLogSourceTracker
|
|
): void {
|
|
this.taskChangePresenceRepository = repository;
|
|
this.teamLogSourceTracker = tracker;
|
|
}
|
|
|
|
/** Получить все изменения агента */
|
|
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet> {
|
|
const cacheKey = `${teamName}:${memberName}`;
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.data;
|
|
}
|
|
|
|
const projectPath = await this.resolveProjectPath(teamName);
|
|
const { result, latestMtime } = await this.taskChangeComputer.computeAgentChanges(
|
|
teamName,
|
|
memberName,
|
|
projectPath
|
|
);
|
|
|
|
this.cache.set(cacheKey, {
|
|
data: result,
|
|
mtime: latestMtime,
|
|
expiresAt: Date.now() + this.cacheTtl,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
|
|
async getTaskChanges(
|
|
teamName: string,
|
|
taskId: string,
|
|
options?: {
|
|
owner?: string;
|
|
status?: string;
|
|
intervals?: { startedAt: string; completedAt?: string }[];
|
|
since?: string;
|
|
stateBucket?: TaskChangeStateBucket;
|
|
summaryOnly?: boolean;
|
|
forceFresh?: boolean;
|
|
}
|
|
): Promise<TaskChangeSetV2> {
|
|
const initialVersion = this.getTaskChangeSummaryVersion(teamName, taskId);
|
|
const includeDetails = options?.summaryOnly !== true;
|
|
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
|
const effectiveOptions: TaskChangeEffectiveOptions = {
|
|
owner: options?.owner ?? taskMeta?.owner,
|
|
status: options?.status ?? taskMeta?.status,
|
|
intervals: options?.intervals ?? taskMeta?.intervals,
|
|
since: options?.since,
|
|
};
|
|
const projectPath = await this.resolveProjectPath(teamName);
|
|
const effectiveStateBucket = taskMeta
|
|
? getTaskChangeStateBucket({
|
|
status: effectiveOptions.status,
|
|
reviewState: taskMeta.reviewState,
|
|
historyEvents: taskMeta.historyEvents,
|
|
kanbanColumn: taskMeta.kanbanColumn,
|
|
})
|
|
: (options?.stateBucket ??
|
|
getTaskChangeStateBucket({
|
|
status: effectiveOptions.status,
|
|
}));
|
|
const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket);
|
|
const shouldUseSummaryCache = !includeDetails && summaryCacheableState;
|
|
|
|
let version = initialVersion;
|
|
if (!summaryCacheableState || options?.forceFresh === true) {
|
|
await this.invalidateTaskChangeSummaries(teamName, [taskId], {
|
|
deletePersisted: true,
|
|
});
|
|
version = this.getTaskChangeSummaryVersion(teamName, taskId);
|
|
}
|
|
|
|
const resolvedInput: ResolvedTaskChangeComputeInput = {
|
|
teamName,
|
|
taskId,
|
|
taskMeta,
|
|
effectiveOptions,
|
|
projectPath,
|
|
includeDetails,
|
|
};
|
|
|
|
if (!shouldUseSummaryCache) {
|
|
const result = await this.computeTaskChangesPreferred(resolvedInput);
|
|
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
|
|
return result;
|
|
}
|
|
|
|
const cacheKey = this.buildTaskChangeSummaryCacheKey(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions,
|
|
effectiveStateBucket
|
|
);
|
|
|
|
if (options?.forceFresh !== true) {
|
|
const cached = this.taskChangeSummaryCache.get(cacheKey);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
await this.recordTaskChangePresence(
|
|
teamName,
|
|
taskId,
|
|
taskMeta,
|
|
effectiveOptions,
|
|
cached.data
|
|
);
|
|
return cached.data;
|
|
}
|
|
this.taskChangeSummaryCache.delete(cacheKey);
|
|
|
|
const inFlight = this.taskChangeSummaryInFlight.get(cacheKey);
|
|
if (inFlight) {
|
|
return inFlight;
|
|
}
|
|
|
|
const persisted = await this.readPersistedTaskChangeSummary(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions,
|
|
effectiveStateBucket,
|
|
taskMeta
|
|
);
|
|
if (persisted) {
|
|
this.setTaskChangeSummaryCache(cacheKey, persisted);
|
|
await this.recordTaskChangePresence(
|
|
teamName,
|
|
taskId,
|
|
taskMeta,
|
|
effectiveOptions,
|
|
persisted
|
|
);
|
|
return persisted;
|
|
}
|
|
}
|
|
|
|
const promise = this.computeTaskChangesPreferred({ ...resolvedInput, includeDetails: false })
|
|
.then(async (result) => {
|
|
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) {
|
|
return result;
|
|
}
|
|
|
|
this.setTaskChangeSummaryCache(cacheKey, result);
|
|
await this.persistTaskChangeSummary(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions,
|
|
effectiveStateBucket,
|
|
result,
|
|
version
|
|
);
|
|
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
|
|
return result;
|
|
})
|
|
.finally(() => {
|
|
this.taskChangeSummaryInFlight.delete(cacheKey);
|
|
});
|
|
|
|
this.taskChangeSummaryInFlight.set(cacheKey, promise);
|
|
return promise;
|
|
}
|
|
|
|
async invalidateTaskChangeSummaries(
|
|
teamName: string,
|
|
taskIds: string[],
|
|
options?: { deletePersisted?: boolean }
|
|
): Promise<void> {
|
|
const uniqueTaskIds = [...new Set(taskIds.filter((taskId) => taskId.length > 0))];
|
|
await Promise.all(
|
|
uniqueTaskIds.map(async (taskId) => {
|
|
this.bumpTaskChangeSummaryVersion(teamName, taskId);
|
|
for (const key of [...this.taskChangeSummaryCache.keys()]) {
|
|
if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) {
|
|
this.taskChangeSummaryCache.delete(key);
|
|
}
|
|
}
|
|
for (const key of [...this.taskChangeSummaryInFlight.keys()]) {
|
|
if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) {
|
|
this.taskChangeSummaryInFlight.delete(key);
|
|
}
|
|
}
|
|
if (options?.deletePersisted !== false && this.isPersistedTaskChangeCacheEnabled) {
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
private async computeTaskChangesPreferred(
|
|
input: ResolvedTaskChangeComputeInput
|
|
): Promise<TaskChangeSetV2> {
|
|
if (!this.taskChangeWorkerClient.isAvailable()) {
|
|
return this.taskChangeComputer.computeTaskChanges(input);
|
|
}
|
|
|
|
try {
|
|
const result = await this.taskChangeWorkerClient.computeTaskChanges(input);
|
|
if (this.isValidWorkerTaskChangeResult(result, input)) {
|
|
return result;
|
|
}
|
|
logger.warn(
|
|
`Task change worker returned malformed result for ${input.teamName}/${input.taskId}; falling back inline.`
|
|
);
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Task change worker failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
|
|
return this.taskChangeComputer.computeTaskChanges(input);
|
|
}
|
|
|
|
private isValidWorkerTaskChangeResult(
|
|
result: TaskChangeSetV2,
|
|
input: ResolvedTaskChangeComputeInput
|
|
): boolean {
|
|
return (
|
|
!!result &&
|
|
typeof result === 'object' &&
|
|
result.teamName === input.teamName &&
|
|
result.taskId === input.taskId &&
|
|
Array.isArray(result.files)
|
|
);
|
|
}
|
|
|
|
/** Получить краткую статистику */
|
|
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats> {
|
|
const changes = await this.getAgentChanges(teamName, memberName);
|
|
return {
|
|
linesAdded: changes.totalLinesAdded,
|
|
linesRemoved: changes.totalLinesRemoved,
|
|
filesChanged: changes.totalFiles,
|
|
};
|
|
}
|
|
|
|
// ---- Private methods ----
|
|
|
|
/** Read task metadata (owner, status) from the task JSON file */
|
|
private async readTaskMeta(teamName: string, taskId: string): Promise<TaskChangeTaskMeta | null> {
|
|
try {
|
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
|
const raw = await readFile(taskPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
const intervals = Array.isArray(parsed.workIntervals)
|
|
? (parsed.workIntervals as unknown[]).filter(
|
|
(i): i is { startedAt: string; completedAt?: string } =>
|
|
Boolean(i) &&
|
|
typeof i === 'object' &&
|
|
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
|
((i as Record<string, unknown>).completedAt === undefined ||
|
|
typeof (i as Record<string, unknown>).completedAt === 'string')
|
|
)
|
|
: undefined;
|
|
|
|
const derivedIntervals = (() => {
|
|
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
|
|
const rawHistory = parsed.historyEvents;
|
|
if (!Array.isArray(rawHistory)) return undefined;
|
|
|
|
const transitions = rawHistory
|
|
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
|
|
.filter((h): h is Record<string, unknown> => h !== null)
|
|
.filter((h) => h.type === 'status_changed')
|
|
.map((h) => ({
|
|
to: typeof h.to === 'string' ? h.to : null,
|
|
timestamp: typeof h.timestamp === 'string' ? h.timestamp : null,
|
|
}))
|
|
.filter(
|
|
(t): t is { to: string; timestamp: string } => t.to !== null && t.timestamp !== null
|
|
)
|
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
|
|
if (transitions.length === 0) return undefined;
|
|
|
|
const derived: { startedAt: string; completedAt?: string }[] = [];
|
|
let currentStart: string | null = null;
|
|
for (const t of transitions) {
|
|
if (t.to === 'in_progress') {
|
|
if (!currentStart) currentStart = t.timestamp;
|
|
continue;
|
|
}
|
|
if (currentStart) {
|
|
derived.push({ startedAt: currentStart, completedAt: t.timestamp });
|
|
currentStart = null;
|
|
}
|
|
}
|
|
if (currentStart) derived.push({ startedAt: currentStart });
|
|
|
|
return derived.length > 0 ? derived : undefined;
|
|
})();
|
|
return {
|
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
|
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
|
intervals: derivedIntervals,
|
|
reviewState:
|
|
parsed.reviewState === 'review' ||
|
|
parsed.reviewState === 'needsFix' ||
|
|
parsed.reviewState === 'approved'
|
|
? parsed.reviewState
|
|
: 'none',
|
|
historyEvents: Array.isArray(parsed.historyEvents) ? parsed.historyEvents : undefined,
|
|
kanbanColumn:
|
|
parsed.kanbanColumn === 'review' || parsed.kanbanColumn === 'approved'
|
|
? parsed.kanbanColumn
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Получить projectPath из конфига команды */
|
|
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
|
|
try {
|
|
const config = await this.configReader.getConfig(teamName);
|
|
return config?.projectPath?.trim() || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private buildTaskChangeSummaryCacheKey(
|
|
teamName: string,
|
|
taskId: string,
|
|
options: TaskChangeEffectiveOptions,
|
|
stateBucket: TaskChangeStateBucket
|
|
): string {
|
|
return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`;
|
|
}
|
|
|
|
private normalizeFilePathKey(filePath: string): string {
|
|
return normalizeTaskChangePresenceFilePath(filePath);
|
|
}
|
|
|
|
private buildTaskSignature(
|
|
options: TaskChangeEffectiveOptions,
|
|
stateBucket: TaskChangeStateBucket
|
|
): string {
|
|
const owner = typeof options.owner === 'string' ? options.owner.trim() : '';
|
|
const status = typeof options.status === 'string' ? options.status.trim() : '';
|
|
const since = typeof options.since === 'string' ? options.since : '';
|
|
const intervals = Array.isArray(options.intervals)
|
|
? options.intervals.map((interval) => ({
|
|
startedAt: interval.startedAt,
|
|
completedAt: interval.completedAt ?? '',
|
|
}))
|
|
: [];
|
|
return JSON.stringify({ owner, status, since, stateBucket, intervals });
|
|
}
|
|
|
|
private setTaskChangeSummaryCache(cacheKey: string, result: TaskChangeSetV2): void {
|
|
this.pruneExpiredTaskChangeSummaryCache();
|
|
this.taskChangeSummaryCache.set(cacheKey, {
|
|
data: result,
|
|
expiresAt:
|
|
Date.now() +
|
|
(result.files.length > 0
|
|
? this.taskChangeSummaryCacheTtl
|
|
: this.emptyTaskChangeSummaryCacheTtl),
|
|
});
|
|
while (this.taskChangeSummaryCache.size > this.maxTaskChangeSummaryCacheEntries) {
|
|
const oldestKey = this.taskChangeSummaryCache.keys().next().value;
|
|
if (!oldestKey) break;
|
|
this.taskChangeSummaryCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
private pruneExpiredTaskChangeSummaryCache(): void {
|
|
const now = Date.now();
|
|
for (const [key, entry] of this.taskChangeSummaryCache.entries()) {
|
|
if (entry.expiresAt <= now) {
|
|
this.taskChangeSummaryCache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private getTaskChangeSummaryVersionKey(teamName: string, taskId: string): string {
|
|
return `${teamName}:${taskId}`;
|
|
}
|
|
|
|
private getTaskChangeSummaryVersion(teamName: string, taskId: string): number {
|
|
return (
|
|
this.taskChangeSummaryVersionByTask.get(
|
|
this.getTaskChangeSummaryVersionKey(teamName, taskId)
|
|
) ?? 0
|
|
);
|
|
}
|
|
|
|
private bumpTaskChangeSummaryVersion(teamName: string, taskId: string): void {
|
|
const key = this.getTaskChangeSummaryVersionKey(teamName, taskId);
|
|
this.taskChangeSummaryVersionByTask.set(
|
|
key,
|
|
this.getTaskChangeSummaryVersion(teamName, taskId) + 1
|
|
);
|
|
}
|
|
|
|
private isTaskChangeSummaryCacheKeyForTask(
|
|
cacheKey: string,
|
|
teamName: string,
|
|
taskId: string
|
|
): boolean {
|
|
return cacheKey.startsWith(`${teamName}:${taskId}:`);
|
|
}
|
|
|
|
private async readPersistedTaskChangeSummary(
|
|
teamName: string,
|
|
taskId: string,
|
|
effectiveOptions: TaskChangeEffectiveOptions,
|
|
stateBucket: TaskChangeStateBucket,
|
|
taskMeta: TaskChangeTaskMeta | null
|
|
): Promise<TaskChangeSetV2 | null> {
|
|
if (!this.isPersistedTaskChangeCacheEnabled) {
|
|
return null;
|
|
}
|
|
if (!taskMeta || !isTaskChangeSummaryCacheable(stateBucket)) {
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
return null;
|
|
}
|
|
|
|
const currentBucket = getTaskChangeStateBucket({
|
|
status: taskMeta.status,
|
|
reviewState: taskMeta.reviewState,
|
|
historyEvents: taskMeta.historyEvents,
|
|
kanbanColumn: taskMeta.kanbanColumn,
|
|
});
|
|
if (!isTaskChangeSummaryCacheable(currentBucket)) {
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
return null;
|
|
}
|
|
|
|
const entry = await this.taskChangeSummaryRepository.load(teamName, taskId);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
const projectFingerprint = await this.computeProjectFingerprint(teamName);
|
|
const taskSignature = this.buildTaskSignature(effectiveOptions, currentBucket);
|
|
|
|
if (
|
|
!projectFingerprint ||
|
|
entry.taskSignature !== taskSignature ||
|
|
entry.projectFingerprint !== projectFingerprint ||
|
|
entry.stateBucket !== currentBucket
|
|
) {
|
|
logger.debug(`Rejecting persisted task-change summary for ${teamName}/${taskId}`);
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
return null;
|
|
}
|
|
|
|
this.schedulePersistedTaskChangeSummaryValidation(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions,
|
|
currentBucket,
|
|
entry.sourceFingerprint
|
|
);
|
|
|
|
return entry.summary;
|
|
}
|
|
|
|
private schedulePersistedTaskChangeSummaryValidation(
|
|
teamName: string,
|
|
taskId: string,
|
|
effectiveOptions: TaskChangeEffectiveOptions,
|
|
expectedBucket: TaskChangeStateBucket,
|
|
expectedSourceFingerprint: string
|
|
): void {
|
|
const validationKey = `${teamName}:${taskId}`;
|
|
if (this.taskChangeSummaryValidationInFlight.has(validationKey)) {
|
|
return;
|
|
}
|
|
|
|
const version = this.getTaskChangeSummaryVersion(teamName, taskId);
|
|
this.taskChangeSummaryValidationInFlight.add(validationKey);
|
|
|
|
setTimeout(() => {
|
|
void this.validatePersistedTaskChangeSummary(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions,
|
|
expectedBucket,
|
|
expectedSourceFingerprint,
|
|
version
|
|
)
|
|
.catch((error) => {
|
|
logger.debug(
|
|
`Background persisted summary validation failed for ${teamName}/${taskId}: ${String(error)}`
|
|
);
|
|
})
|
|
.finally(() => {
|
|
this.taskChangeSummaryValidationInFlight.delete(validationKey);
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
private async validatePersistedTaskChangeSummary(
|
|
teamName: string,
|
|
taskId: string,
|
|
effectiveOptions: TaskChangeEffectiveOptions,
|
|
expectedBucket: TaskChangeStateBucket,
|
|
expectedSourceFingerprint: string,
|
|
version: number
|
|
): Promise<void> {
|
|
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) {
|
|
return;
|
|
}
|
|
|
|
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
|
if (!taskMeta) {
|
|
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
|
|
return;
|
|
}
|
|
|
|
const currentBucket = getTaskChangeStateBucket({
|
|
status: taskMeta.status ?? effectiveOptions.status,
|
|
reviewState: taskMeta.reviewState,
|
|
historyEvents: taskMeta.historyEvents,
|
|
kanbanColumn: taskMeta.kanbanColumn,
|
|
});
|
|
if (!isTaskChangeSummaryCacheable(currentBucket) || currentBucket !== expectedBucket) {
|
|
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
|
|
return;
|
|
}
|
|
|
|
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions
|
|
);
|
|
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
|
if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) {
|
|
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
|
|
}
|
|
}
|
|
|
|
private async persistTaskChangeSummary(
|
|
teamName: string,
|
|
taskId: string,
|
|
effectiveOptions: TaskChangeEffectiveOptions,
|
|
stateBucket: TaskChangeStateBucket,
|
|
result: TaskChangeSetV2,
|
|
generation: number
|
|
): Promise<void> {
|
|
if (!this.isPersistedTaskChangeCacheEnabled) return;
|
|
if (!isTaskChangeSummaryCacheable(stateBucket)) return;
|
|
if (result.files.length === 0) return;
|
|
if (result.confidence !== 'high' && result.confidence !== 'medium') {
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
return;
|
|
}
|
|
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== generation) {
|
|
return;
|
|
}
|
|
const currentTaskMeta = await this.readTaskMeta(teamName, taskId);
|
|
if (!currentTaskMeta) return;
|
|
const currentBucket = getTaskChangeStateBucket({
|
|
status: currentTaskMeta.status ?? effectiveOptions.status,
|
|
reviewState: currentTaskMeta.reviewState,
|
|
historyEvents: currentTaskMeta.historyEvents,
|
|
kanbanColumn: currentTaskMeta.kanbanColumn,
|
|
});
|
|
if (!isTaskChangeSummaryCacheable(currentBucket)) {
|
|
await this.taskChangeSummaryRepository.delete(teamName, taskId);
|
|
return;
|
|
}
|
|
|
|
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
|
teamName,
|
|
taskId,
|
|
effectiveOptions
|
|
);
|
|
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
|
const projectFingerprint = await this.computeProjectFingerprint(teamName);
|
|
if (!sourceFingerprint || !projectFingerprint) {
|
|
return;
|
|
}
|
|
|
|
const expiresAt = new Date(Date.now() + this.persistedTaskChangeSummaryTtl).toISOString();
|
|
await this.taskChangeSummaryRepository.save(
|
|
{
|
|
version: 1,
|
|
teamName,
|
|
taskId,
|
|
stateBucket: currentBucket === 'approved' ? 'approved' : 'completed',
|
|
taskSignature: this.buildTaskSignature(effectiveOptions, currentBucket),
|
|
sourceFingerprint,
|
|
projectFingerprint,
|
|
writtenAt: new Date().toISOString(),
|
|
expiresAt,
|
|
extractorConfidence: result.confidence,
|
|
summary: result,
|
|
debugMeta: {
|
|
sourceCount: logRefs.length,
|
|
projectPathHash: projectFingerprint,
|
|
},
|
|
},
|
|
{ generation }
|
|
);
|
|
}
|
|
|
|
private async computeSourceFingerprint(logRefs: LogFileRef[]): Promise<string | null> {
|
|
if (logRefs.length === 0) return null;
|
|
const parts: string[] = [];
|
|
for (const ref of [...logRefs].sort((a, b) => a.filePath.localeCompare(b.filePath))) {
|
|
try {
|
|
const stats = await stat(ref.filePath);
|
|
parts.push(`${this.normalizeFilePathKey(ref.filePath)}:${stats.size}:${stats.mtimeMs}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return createHash('sha256').update(parts.join('|')).digest('hex');
|
|
}
|
|
|
|
private async computeProjectFingerprint(teamName: string): Promise<string | null> {
|
|
const projectPath = await this.resolveProjectPath(teamName);
|
|
return computeTaskChangePresenceProjectFingerprint(projectPath);
|
|
}
|
|
|
|
private async recordTaskChangePresence(
|
|
teamName: string,
|
|
taskId: string,
|
|
taskMeta: TaskChangeTaskMeta | null,
|
|
effectiveOptions: TaskChangeEffectiveOptions,
|
|
result: TaskChangeSetV2
|
|
): Promise<void> {
|
|
if (!this.taskChangePresenceRepository || !this.teamLogSourceTracker || !taskMeta) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = await this.teamLogSourceTracker.ensureTracking(teamName);
|
|
if (!snapshot.projectFingerprint || !snapshot.logSourceGeneration) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
result.files.length === 0 &&
|
|
result.confidence !== 'high' &&
|
|
result.confidence !== 'medium'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const descriptor = buildTaskChangePresenceDescriptor({
|
|
owner: effectiveOptions.owner ?? taskMeta.owner,
|
|
status: effectiveOptions.status ?? taskMeta.status,
|
|
intervals: effectiveOptions.intervals ?? taskMeta.intervals,
|
|
since: effectiveOptions.since,
|
|
reviewState: taskMeta.reviewState,
|
|
historyEvents: taskMeta.historyEvents,
|
|
kanbanColumn: taskMeta.kanbanColumn,
|
|
});
|
|
|
|
const now = new Date().toISOString();
|
|
await this.taskChangePresenceRepository.upsertEntry(
|
|
teamName,
|
|
{
|
|
projectFingerprint: snapshot.projectFingerprint,
|
|
logSourceGeneration: snapshot.logSourceGeneration,
|
|
writtenAt: now,
|
|
},
|
|
{
|
|
taskId,
|
|
taskSignature: descriptor.taskSignature,
|
|
presence: result.files.length > 0 ? 'has_changes' : 'no_changes',
|
|
writtenAt: now,
|
|
logSourceGeneration: snapshot.logSourceGeneration,
|
|
}
|
|
);
|
|
}
|
|
}
|