agent-ecosystem/src/main/services/team/ChangeExtractorService.ts
Илия 11bb49c53e
feat(graph): force-directed agent graph visualization with kanban-zone task layout
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
2026-03-28 12:03:42 +02:00

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,
}
);
}
}