Merge branch 'dev' into feature/extensions-skills

This commit is contained in:
iliya 2026-03-11 21:55:13 +02:00
commit 5abd8a0ceb
23 changed files with 2984 additions and 1114 deletions

View file

@ -16,6 +16,7 @@ import {
REVIEW_GET_FILE_CONTENT,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
REVIEW_LOAD_DECISIONS,
REVIEW_PREVIEW_REJECT,
REVIEW_REJECT_FILE,
@ -91,6 +92,7 @@ export function registerReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges);
ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges);
ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries);
ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats);
// Phase 2
ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict);
@ -113,6 +115,7 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES);
ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES);
ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES);
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
// Phase 2
ipcMain.removeHandler(REVIEW_CHECK_CONFLICT);
@ -174,7 +177,19 @@ async function handleGetTaskChanges(
typeof (i as Record<string, unknown>).completedAt === 'string')
) as { startedAt: string; completedAt?: string }[])
: undefined,
stateBucket:
(options as Record<string, unknown>).stateBucket === 'approved' ||
(options as Record<string, unknown>).stateBucket === 'review' ||
(options as Record<string, unknown>).stateBucket === 'completed' ||
(options as Record<string, unknown>).stateBucket === 'active'
? ((options as Record<string, unknown>).stateBucket as
| 'approved'
| 'review'
| 'completed'
| 'active')
: undefined,
summaryOnly: (options as Record<string, unknown>).summaryOnly === true,
forceFresh: (options as Record<string, unknown>).forceFresh === true,
}
: undefined;
@ -183,6 +198,19 @@ async function handleGetTaskChanges(
);
}
async function handleInvalidateTaskChangeSummaries(
_event: IpcMainInvokeEvent,
teamName: string,
taskIds: string[]
): Promise<IpcResult<void>> {
return wrapReviewHandler('invalidateTaskChangeSummaries', async () => {
await getChangeExtractor().invalidateTaskChangeSummaries(
teamName,
Array.isArray(taskIds) ? taskIds.filter((taskId) => typeof taskId === 'string') : []
);
});
}
async function handleGetChangeStats(
_event: IpcMainInvokeEvent,
teamName: string,

View file

@ -1,10 +1,17 @@
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 { createReadStream } from 'fs';
import { readFile, stat } from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { TeamConfigReader } from './TeamConfigReader';
import { countLineChanges } from './UnifiedLineCounter';
@ -31,7 +38,7 @@ interface CacheEntry {
expiresAt: number;
}
interface TaskChangeCacheEntry {
interface TaskChangeSummaryCacheEntry {
data: TaskChangeSetV2;
expiresAt: number;
}
@ -50,16 +57,25 @@ interface LogFileRef {
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeCache = new Map<string, TaskChangeCacheEntry>();
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 parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
private readonly taskChangeSummaryCacheTtl = 60 * 1000;
private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000;
private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000;
private readonly maxTaskChangeSummaryCacheEntries = 200;
private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets
private readonly isPersistedTaskChangeCacheEnabled =
process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0';
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader()
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository()
) {}
/** Получить все изменения агента */
@ -128,7 +144,9 @@ export class ChangeExtractorService {
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
stateBucket?: TaskChangeStateBucket;
summaryOnly?: boolean;
forceFresh?: boolean;
}
): Promise<TaskChangeSetV2> {
const includeDetails = options?.summaryOnly !== true;
@ -139,28 +157,130 @@ export class ChangeExtractorService {
intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since,
};
const cacheKey = this.buildTaskChangeCacheKey(
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;
if (!summaryCacheableState || options?.forceFresh === true) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], {
deletePersisted: true,
});
}
if (!shouldUseSummaryCache) {
return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails);
}
const cacheKey = this.buildTaskChangeSummaryCacheKey(
teamName,
taskId,
effectiveOptions,
includeDetails
effectiveStateBucket
);
const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined;
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
const version = this.getTaskChangeSummaryVersion(teamName, taskId);
if (options?.forceFresh !== true) {
const cached = this.taskChangeSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
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);
return persisted;
}
}
const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, 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
);
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 computeTaskChanges(
teamName: string,
taskId: string,
effectiveOptions: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
includeDetails: boolean
): Promise<TaskChangeSetV2> {
const taskMeta = await this.readTaskMeta(teamName, taskId);
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
const empty = this.emptyTaskChangeSet(teamName, taskId);
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: empty,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return empty;
return this.emptyTaskChangeSet(teamName, taskId);
}
const projectPath = await this.resolveProjectPath(teamName);
@ -182,23 +302,7 @@ export class ChangeExtractorService {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
const intervalScope: TaskChangeScope = {
taskId,
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
startLine: 0,
endLine: 0,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: files.map((f) => f.filePath),
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
};
const intervalResult: TaskChangeSetV2 = {
return {
teamName,
taskId,
files,
@ -207,39 +311,32 @@ export class ChangeExtractorService {
totalFiles: files.length,
confidence: 'medium',
computedAt: new Date().toISOString(),
scope: intervalScope,
scope: {
taskId,
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
startLine: 0,
endLine: 0,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: files.map((f) => f.filePath),
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings:
files.length === 0
? ['No file edits found within persisted workIntervals.']
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
};
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: intervalResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return intervalResult;
}
const fallbackResult = await this.fallbackSingleTaskScope(
teamName,
taskId,
logRefs,
projectPath,
includeDetails
);
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: fallbackResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return fallbackResult;
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds));
const files = await this.extractFilteredChanges(
logRefs,
allowedToolUseIds,
@ -247,31 +344,19 @@ export class ChangeExtractorService {
includeDetails
);
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
const warnings: string[] = [];
if (worstTier >= 3) {
warnings.push('Some task boundaries could not be precisely determined.');
}
const result: TaskChangeSetV2 = {
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low',
computedAt: new Date().toISOString(),
scope: allScopes[0],
warnings,
warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [],
};
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: result,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return result;
}
/** Получить краткую статистику */
@ -294,6 +379,9 @@ export class ChangeExtractorService {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved';
} | null> {
try {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
@ -350,6 +438,17 @@ export class ChangeExtractorService {
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)}`);
@ -927,7 +1026,7 @@ export class ChangeExtractorService {
};
}
private buildTaskChangeCacheKey(
private buildTaskChangeSummaryCacheKey(
teamName: string,
taskId: string,
options: {
@ -936,7 +1035,24 @@ export class ChangeExtractorService {
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
includeDetails: boolean
stateBucket: TaskChangeStateBucket
): string {
return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`;
}
private normalizeFilePathKey(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase());
}
private buildTaskSignature(
options: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
stateBucket: TaskChangeStateBucket
): string {
const owner = typeof options.owner === 'string' ? options.owner.trim() : '';
const status = typeof options.status === 'string' ? options.status.trim() : '';
@ -947,21 +1063,295 @@ export class ChangeExtractorService {
completedAt: interval.completedAt ?? '',
}))
: [];
return JSON.stringify({ owner, status, since, stateBucket, intervals });
}
return JSON.stringify({
type: 'task',
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: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
stateBucket: TaskChangeStateBucket,
taskMeta: {
status?: string;
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved';
} | 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,
includeDetails,
owner,
status,
since,
intervals,
});
effectiveOptions,
currentBucket,
entry.sourceFingerprint
);
return entry.summary;
}
private normalizeFilePathKey(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase());
private schedulePersistedTaskChangeSummaryValidation(
teamName: string,
taskId: string,
effectiveOptions: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
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: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
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 logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
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: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
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 logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
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('sha1').update(parts.join('|')).digest('hex');
}
private async computeProjectFingerprint(teamName: string): Promise<string | null> {
const projectPath = await this.resolveProjectPath(teamName);
if (!projectPath) return null;
return createHash('sha1').update(this.normalizeFilePathKey(projectPath)).digest('hex');
}
}

View file

@ -0,0 +1,183 @@
import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import {
normalizePersistedTaskChangeSummaryEntry,
toPersistedSummary,
} from './taskChangeSummaryCacheSchema';
import type { TaskChangeSummaryCacheRepository } from './TaskChangeSummaryCacheRepository';
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
const logger = createLogger('Service:JsonTaskChangeSummaryCacheRepository');
const READ_TIMEOUT_MS = 5_000;
const MAX_ENTRY_BYTES = 512 * 1024;
const MAX_CACHE_FILES = 1_000;
function encodeFileSegment(value: string): string {
return encodeURIComponent(value);
}
export class JsonTaskChangeSummaryCacheRepository implements TaskChangeSummaryCacheRepository {
private readonly latestGenerationByKey = new Map<string, number>();
private readonly writeChains = new Map<string, Promise<void>>();
private get basePath(): string {
return getTaskChangeSummariesBasePath();
}
private teamDir(teamName: string): string {
return path.join(this.basePath, encodeFileSegment(teamName));
}
private filePath(teamName: string, taskId: string): string {
return path.join(this.teamDir(teamName), `${encodeFileSegment(taskId)}.json`);
}
async load(teamName: string, taskId: string): Promise<PersistedTaskChangeSummaryEntry | null> {
const filePath = this.filePath(teamName, taskId);
let content: string;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
try {
content = await fs.promises.readFile(filePath, {
encoding: 'utf8',
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.warn(`Failed to read persisted task-change summary ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(content) as unknown;
} catch (error) {
logger.warn(`Corrupted persisted task-change summary ${filePath}: ${String(error)}`);
await this.delete(teamName, taskId);
return null;
}
const normalized = normalizePersistedTaskChangeSummaryEntry(parsed);
if (!normalized) {
await this.delete(teamName, taskId);
return null;
}
if (new Date(normalized.expiresAt).getTime() <= Date.now()) {
await this.delete(teamName, taskId);
return null;
}
return normalized;
}
async save(
entry: PersistedTaskChangeSummaryEntry,
options?: { generation?: number }
): Promise<{ written: boolean }> {
const cacheKey = `${entry.teamName}:${entry.taskId}`;
const generation = options?.generation;
const currentGeneration = this.latestGenerationByKey.get(cacheKey);
if (
generation !== undefined &&
currentGeneration !== undefined &&
generation < currentGeneration
) {
return { written: false };
}
if (generation !== undefined) {
this.latestGenerationByKey.set(cacheKey, generation);
}
const write = async (): Promise<{ written: boolean }> => {
const normalized = toPersistedSummary(entry);
const payload = JSON.stringify(normalized, null, 2);
if (Buffer.byteLength(payload, 'utf8') > MAX_ENTRY_BYTES) {
logger.warn(`Skipping oversized persisted task-change summary for ${cacheKey}`);
return { written: false };
}
await atomicWriteAsync(this.filePath(entry.teamName, entry.taskId), payload);
await this.prune();
return { written: true };
};
const previous = this.writeChains.get(cacheKey) ?? Promise.resolve();
let result: { written: boolean } = { written: false };
const next = previous
.catch(() => undefined)
.then(async () => {
result = await write();
})
.finally(() => {
if (this.writeChains.get(cacheKey) === next) {
this.writeChains.delete(cacheKey);
}
});
this.writeChains.set(cacheKey, next);
await next;
return result;
}
async delete(teamName: string, taskId: string): Promise<void> {
const cacheKey = `${teamName}:${taskId}`;
this.latestGenerationByKey.delete(cacheKey);
await fs.promises.unlink(this.filePath(teamName, taskId)).catch(() => undefined);
}
async prune(): Promise<number> {
let teamDirs: string[] = [];
try {
teamDirs = await fs.promises.readdir(this.basePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return 0;
}
logger.warn(`Failed to read persisted summary cache dir: ${String(error)}`);
return 0;
}
const files: { path: string; mtimeMs: number }[] = [];
for (const dirName of teamDirs) {
const teamPath = path.join(this.basePath, dirName);
let taskFiles: string[] = [];
try {
taskFiles = await fs.promises.readdir(teamPath);
} catch {
continue;
}
for (const taskFile of taskFiles) {
const fullPath = path.join(teamPath, taskFile);
try {
const stats = await fs.promises.stat(fullPath);
files.push({ path: fullPath, mtimeMs: stats.mtimeMs });
} catch {
// best effort
}
}
}
if (files.length <= MAX_CACHE_FILES) {
return 0;
}
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
const toDelete = files.slice(0, files.length - MAX_CACHE_FILES);
await Promise.all(toDelete.map((file) => fs.promises.unlink(file.path).catch(() => undefined)));
return toDelete.length;
}
}

View file

@ -0,0 +1,11 @@
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
export interface TaskChangeSummaryCacheRepository {
load(teamName: string, taskId: string): Promise<PersistedTaskChangeSummaryEntry | null>;
save(
entry: PersistedTaskChangeSummaryEntry,
options?: { generation?: number }
): Promise<{ written: boolean }>;
delete(teamName: string, taskId: string): Promise<void>;
prune(): Promise<number>;
}

View file

@ -0,0 +1,159 @@
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
function normalizeIsoString(value: unknown): string | null {
if (typeof value !== 'string' || value.trim() === '') return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function normalizeString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
function normalizeFileSummary(value: unknown): FileChangeSummary | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<FileChangeSummary>;
if (typeof candidate.filePath !== 'string' || typeof candidate.relativePath !== 'string') {
return null;
}
return {
filePath: candidate.filePath,
relativePath: candidate.relativePath,
snippets: [],
linesAdded: Number.isFinite(candidate.linesAdded) ? Number(candidate.linesAdded) : 0,
linesRemoved: Number.isFinite(candidate.linesRemoved) ? Number(candidate.linesRemoved) : 0,
isNewFile: candidate.isNewFile === true,
};
}
function normalizeSummary(
value: unknown,
teamName: string,
taskId: string
): TaskChangeSetV2 | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<TaskChangeSetV2>;
const files = Array.isArray(candidate.files)
? candidate.files
.map(normalizeFileSummary)
.filter((file): file is FileChangeSummary => file !== null)
: null;
const confidence =
candidate.confidence === 'high' || candidate.confidence === 'medium'
? candidate.confidence
: null;
const computedAt = normalizeIsoString(candidate.computedAt);
if (
!files ||
!confidence ||
!computedAt ||
!candidate.scope ||
!Array.isArray(candidate.warnings)
) {
return null;
}
return {
teamName,
taskId,
files,
totalFiles: Number.isFinite(candidate.totalFiles) ? Number(candidate.totalFiles) : files.length,
totalLinesAdded: Number.isFinite(candidate.totalLinesAdded)
? Number(candidate.totalLinesAdded)
: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: Number.isFinite(candidate.totalLinesRemoved)
? Number(candidate.totalLinesRemoved)
: files.reduce((sum, file) => sum + file.linesRemoved, 0),
confidence,
computedAt,
scope: candidate.scope,
warnings: candidate.warnings.filter(
(warning): warning is string => typeof warning === 'string'
),
};
}
export function toPersistedSummary(
entry: PersistedTaskChangeSummaryEntry
): PersistedTaskChangeSummaryEntry {
return {
...entry,
version: TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION,
summary: {
...entry.summary,
files: entry.summary.files.map((file) => ({
...file,
snippets: [],
timeline: undefined,
})),
},
};
}
export function normalizePersistedTaskChangeSummaryEntry(
value: unknown
): PersistedTaskChangeSummaryEntry | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<PersistedTaskChangeSummaryEntry>;
if (candidate.version !== TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION) {
return null;
}
const teamName = normalizeString(candidate.teamName);
const taskId = normalizeString(candidate.taskId);
const taskSignature = normalizeString(candidate.taskSignature);
const sourceFingerprint = normalizeString(candidate.sourceFingerprint);
const projectFingerprint = normalizeString(candidate.projectFingerprint);
const writtenAt = normalizeIsoString(candidate.writtenAt);
const expiresAt = normalizeIsoString(candidate.expiresAt);
const stateBucket =
candidate.stateBucket === 'approved' || candidate.stateBucket === 'completed'
? candidate.stateBucket
: null;
const extractorConfidence =
candidate.extractorConfidence === 'high' || candidate.extractorConfidence === 'medium'
? candidate.extractorConfidence
: null;
if (
!teamName ||
!taskId ||
!taskSignature ||
!sourceFingerprint ||
!projectFingerprint ||
!writtenAt ||
!expiresAt ||
!stateBucket ||
!extractorConfidence
) {
return null;
}
const summary = normalizeSummary(candidate.summary, teamName, taskId);
if (!summary) {
return null;
}
return {
version: TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION,
teamName,
taskId,
stateBucket,
taskSignature,
sourceFingerprint,
projectFingerprint,
writtenAt,
expiresAt,
extractorConfidence,
summary,
debugMeta:
candidate.debugMeta && typeof candidate.debugMeta === 'object'
? candidate.debugMeta
: undefined,
};
}

View file

@ -0,0 +1,27 @@
import type { TaskChangeSetV2 } from '@shared/types';
import type { TaskChangeStateBucket } from '@shared/utils/taskChangeState';
export const TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION = 1;
export type PersistedTaskChangeExtractorConfidence = Exclude<
TaskChangeSetV2['confidence'],
'low' | 'fallback'
>;
export interface PersistedTaskChangeSummaryEntry {
version: typeof TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION;
teamName: string;
taskId: string;
stateBucket: Extract<TaskChangeStateBucket, 'approved' | 'completed'>;
taskSignature: string;
sourceFingerprint: string;
projectFingerprint: string;
writtenAt: string;
expiresAt: string;
extractorConfidence: PersistedTaskChangeExtractorConfidence;
summary: TaskChangeSetV2;
debugMeta?: {
sourceCount?: number;
projectPathHash?: string;
};
}

View file

@ -368,3 +368,7 @@ export function getToolsBasePath(): string {
export function getSchedulesBasePath(): string {
return path.join(getClaudeBasePath(), 'claude-devtools-schedules');
}
export function getTaskChangeSummariesBasePath(): string {
return path.join(getClaudeBasePath(), 'task-change-summaries');
}

View file

@ -431,6 +431,9 @@ export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges';
/** Получить изменения задачи */
export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges';
/** Инвалидировать persisted/in-memory summary cache для задач */
export const REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES = 'review:invalidateTaskChangeSummaries';
/** Получить краткую статистику изменений */
export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats';

View file

@ -57,6 +57,7 @@ import {
REVIEW_GET_FILE_CONTENT,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
REVIEW_LOAD_DECISIONS,
REVIEW_PREVIEW_REJECT,
REVIEW_REJECT_FILE,
@ -1120,7 +1121,9 @@ const electronAPI: ElectronAPI = {
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
summaryOnly?: boolean;
forceFresh?: boolean;
}
) => {
return invokeIpcWithResult<TaskChangeSetV2>(
@ -1130,6 +1133,9 @@ const electronAPI: ElectronAPI = {
options
);
},
invalidateTaskChangeSummaries: async (teamName: string, taskIds: string[]) => {
return invokeIpcWithResult<void>(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, teamName, taskIds);
},
getChangeStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);
},

View file

@ -942,11 +942,16 @@ export class HttpAPIClient implements ElectronAPI {
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
summaryOnly?: boolean;
forceFresh?: boolean;
}
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
invalidateTaskChangeSummaries: async (): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},

View file

@ -1,3 +1,4 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { MessageSquare } from 'lucide-react';
interface UnreadCommentsBadgeProps {
@ -12,16 +13,25 @@ export const UnreadCommentsBadge = ({
if (totalCount === 0) return null;
return (
<span
className={`relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] py-0 text-[10px] font-medium text-[var(--color-text-muted)] ${unreadCount > 0 ? 'mr-1 pl-1.5 pr-2' : 'px-1.5'}`}
>
<MessageSquare size={10} />
{totalCount}
{unreadCount > 0 && (
<span className="absolute -top-1 right-0 flex h-3 min-w-[12px] translate-x-[calc(50%-4px)] items-center justify-center rounded-full bg-blue-500 px-0.5 text-[8px] font-bold leading-none text-white">
{unreadCount}
<Tooltip>
<TooltipTrigger asChild>
<span className="relative inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)]">
<MessageSquare size={14} />
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 min-w-[14px] items-center justify-center rounded-full bg-slate-200 px-1 text-[7px] font-bold leading-none text-slate-700 shadow-sm dark:bg-slate-200 dark:text-slate-900">
{totalCount}
</span>
{unreadCount > 0 ? (
<span className="absolute -right-1.5 -top-1.5 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-blue-500 px-1.5 text-[9px] font-bold leading-none text-white shadow-sm ring-2 ring-[var(--color-surface-raised)]">
{unreadCount}
</span>
) : null}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{unreadCount > 0
? `${unreadCount} unread comments, ${totalCount} total`
: `${totalCount} comments`}
</TooltipContent>
</Tooltip>
);
};

View file

@ -36,6 +36,7 @@ import {
} from '@renderer/utils/memberHelpers';
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { formatDistanceToNow } from 'date-fns';
import {
@ -265,8 +266,8 @@ export const TaskDetailDialog = ({
return result;
}, [currentTask?.comments]);
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
// Lazy-load task changes only for terminal, cacheable states.
const canShowTaskChanges = currentTask ? isTaskChangeSummaryCacheable(currentTask) : false;
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
const taskChangeRequestOptions = useMemo(
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
@ -284,27 +285,30 @@ export const TaskDetailDialog = ({
);
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
const loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
if (
!currentTask ||
!taskChangeSummaryOptions ||
variant !== 'team' ||
!isTaskCompleted ||
!onViewChanges
) {
return null;
}
const data = await api.review.getTaskChanges(
teamName,
currentTask.id,
taskChangeSummaryOptions
);
return data.files;
}, [currentTask, isTaskCompleted, onViewChanges, taskChangeSummaryOptions, teamName, variant]);
const loadTaskChangeSummary = useCallback(
async (forceFresh = false): Promise<FileChangeSummary[] | null> => {
if (
!currentTask ||
!taskChangeSummaryOptions ||
variant !== 'team' ||
!canShowTaskChanges ||
!onViewChanges
) {
return null;
}
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
...taskChangeSummaryOptions,
forceFresh,
});
return data.files;
},
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !isTaskCompleted || !onViewChanges || !changesSectionOpen) return;
if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen)
return;
let cancelled = false;
setTaskChangesLoading(true);
@ -335,6 +339,22 @@ export const TaskDetailDialog = ({
if (!cancelled) setTaskChangesLoading(false);
});
void loadTaskChangeSummary(true)
.then((files) => {
if (!cancelled && files) {
setTaskChangesFiles(files);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(
teamName,
currentTask.id,
taskChangeRequestOptions,
files.length > 0
);
}
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
@ -342,7 +362,7 @@ export const TaskDetailDialog = ({
changesSectionOpen,
open,
currentTask,
isTaskCompleted,
canShowTaskChanges,
teamName,
onViewChanges,
taskSince,
@ -351,10 +371,10 @@ export const TaskDetailDialog = ({
]);
const handleRefreshChanges = useCallback(() => {
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return;
if (!currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges) return;
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary()
void loadTaskChangeSummary(true)
.then((files) => {
setTaskChangesFiles(files ?? null);
if (currentTask && taskChangeRequestOptions) {
@ -370,7 +390,7 @@ export const TaskDetailDialog = ({
.finally(() => setTaskChangesLoading(false));
}, [
currentTask,
isTaskCompleted,
canShowTaskChanges,
onViewChanges,
loadTaskChangeSummary,
recordTaskHasChanges,
@ -810,7 +830,7 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection>
{/* Changes */}
{variant === 'team' && isTaskCompleted && onViewChanges ? (
{variant === 'team' && canShowTaskChanges && onViewChanges ? (
<CollapsibleTeamSection
key={`task-changes:${currentTask.id}`}
title="Changes"

View file

@ -11,6 +11,7 @@ import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/membe
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
isTaskSummaryCacheableForOptions,
} from '@renderer/utils/taskChangeRequest';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
@ -18,8 +19,11 @@ import {
ArrowRightFromLine,
CheckCircle2,
FileCode,
FilePenLine,
GitPullRequestArrow,
HelpCircle,
Play,
RotateCcw,
Trash2,
XCircle,
} from 'lucide-react';
@ -131,18 +135,22 @@ const CancelTaskButton = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="destructive"
size="sm"
className="gap-1"
aria-label={`Cancel task ${taskId}`}
onClick={(e) => e.stopPropagation()}
>
<XCircle size={12} />
Cancel
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="destructive"
size="icon"
className="size-8 rounded-full shadow-sm"
aria-label={`Cancel task ${taskId}`}
onClick={(e) => e.stopPropagation()}
>
<XCircle size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">Cancel</TooltipContent>
</Tooltip>
<PopoverContent
className="w-56 p-3"
side="top"
@ -173,6 +181,37 @@ const CancelTaskButton = ({
);
};
interface TaskActionIconButtonProps {
label: string;
icon: React.ReactNode;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
className: string;
variant?: 'outline' | 'ghost' | 'destructive';
}
const TaskActionIconButton = ({
label,
icon,
onClick,
className,
variant = 'outline',
}: TaskActionIconButtonProps): React.JSX.Element => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={variant}
size="icon"
className={`size-8 shrink-0 rounded-full shadow-sm ${className}`}
aria-label={label}
onClick={onClick}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
export const KanbanTaskCard = ({
task,
teamName,
@ -205,6 +244,10 @@ export const KanbanTaskCard = ({
const showChangesColumn =
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const useTerminalSummaryCache = useMemo(
() => isTaskSummaryCacheableForOptions(taskChangeRequestOptions),
[taskChangeRequestOptions]
);
const cacheKey = useMemo(
() => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions),
[teamName, task.id, taskChangeRequestOptions]
@ -213,12 +256,12 @@ export const KanbanTaskCard = ({
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => {
if (showChangesColumn && task.status === 'completed' && taskHasChanges !== true) {
if (showChangesColumn && useTerminalSummaryCache && taskHasChanges !== true) {
void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions);
}
}, [
showChangesColumn,
task.status,
useTerminalSummaryCache,
task.id,
teamName,
taskHasChanges,
@ -227,41 +270,32 @@ export const KanbanTaskCard = ({
]);
const isReviewManual = columnId === 'review' && !hasReviewers;
const multiButton =
compact ||
columnId === 'todo' ||
columnId === 'in_progress' ||
columnId === 'done' ||
columnId === 'review';
const metaActions = (
<>
{showChangesColumn && taskHasChanges === true ? (
<button
type="button"
<TaskActionIconButton
label="Changes"
icon={<FileCode className="size-3.5" />}
variant="ghost"
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
onClick={(e) => {
e.stopPropagation();
onViewChanges(task.id);
}}
className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)] transition-colors hover:text-blue-400"
>
<FileCode className="size-3" />
Changes
</button>
/>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? (
<button
type="button"
<TaskActionIconButton
label="Delete task"
icon={<Trash2 size={14} />}
variant="ghost"
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={(e) => {
e.stopPropagation();
onDeleteTask(task.id);
}}
className="text-[var(--color-text-muted)] transition-colors hover:text-red-400"
title="Delete task"
>
<Trash2 size={12} />
</button>
/>
) : null}
</>
);
@ -348,143 +382,118 @@ export const KanbanTaskCard = ({
</div>
) : null}
<div className={multiButton ? 'space-y-2' : 'flex items-end gap-2'}>
<div className="flex flex-1 flex-wrap gap-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-nowrap gap-2">
{columnId === 'todo' ? (
<>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Start task ${task.id}`}
<TaskActionIconButton
label="Start"
icon={<Play size={14} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
>
<Play size={12} />
Start
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Complete task ${task.id}`}
/>
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={14} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
/>
</>
) : null}
{columnId === 'in_progress' ? (
<>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Complete task ${task.id}`}
<TaskActionIconButton
label="Complete"
icon={<CheckCircle2 size={14} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
/>
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
</>
) : null}
{columnId === 'done' ? (
<>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Approve task ${task.id}`}
<TaskActionIconButton
label="Approve"
icon={<CheckCircle2 size={14} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
<CheckCircle2 size={12} />
Approve
</Button>
<Button
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
/>
<TaskActionIconButton
label="Request review"
icon={<GitPullRequestArrow size={14} />}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
>
Request Review
</Button>
/>
</>
) : null}
{columnId === 'review' ? (
<div className="w-full space-y-2">
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
{isReviewManual ? (
<div className="flex items-center justify-between">
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
<div className="flex items-center gap-1.5">{metaActions}</div>
<div className="min-w-0 shrink text-[11px] text-[var(--color-text-muted)]">
Manual review
</div>
) : null}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Approve task ${task.id}`}
<div className="flex shrink-0 flex-nowrap gap-2">
<TaskActionIconButton
label="Approve"
icon={<CheckCircle2 size={14} />}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
<CheckCircle2 size={12} />
Approve
</Button>
<Button
/>
<TaskActionIconButton
label="Request changes"
icon={<FilePenLine size={14} />}
variant="destructive"
size="sm"
aria-label={`Request changes for task ${task.id}`}
className="bg-red-500/90 text-white hover:bg-red-500"
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
}}
>
Request Changes
</Button>
/>
</div>
{isReviewManual ? (
<div className="flex shrink-0 items-center gap-1.5">{metaActions}</div>
) : null}
</div>
) : null}
{columnId === 'approved' ? (
<Button
variant="outline"
size="sm"
aria-label={`Disapprove task ${task.id}`}
<TaskActionIconButton
label="Disapprove"
icon={<RotateCcw size={14} />}
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
onClick={(e) => {
e.stopPropagation();
onMoveBackToDone(task.id);
}}
>
Disapprove
</Button>
/>
) : null}
</div>
{!isReviewManual ? (
<div className={`flex items-center gap-1.5 ${multiButton ? 'justify-end' : ''}`}>
{metaActions}
</div>
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
) : null}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,10 @@
import { api } from '@renderer/api';
import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
isTaskSummaryCacheableForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger';
@ -226,6 +231,48 @@ function fireStatusChangeNotification(
.catch(() => undefined);
}
function collectTaskChangeInvalidationState(
teamName: string,
prevTasks: TeamData['tasks'],
nextTasks: TeamData['tasks']
): { cacheKeys: string[]; taskIds: string[] } {
const nextKeys = new Set(
nextTasks.map((task) =>
buildTaskChangePresenceKey(teamName, task.id, buildTaskChangeRequestOptions(task))
)
);
const invalidationKeys: string[] = [];
const invalidationTaskIds = new Set<string>();
for (const task of prevTasks) {
const previousKey = buildTaskChangePresenceKey(
teamName,
task.id,
buildTaskChangeRequestOptions(task)
);
if (!nextKeys.has(previousKey)) {
invalidationKeys.push(previousKey);
invalidationTaskIds.add(task.id);
}
}
return {
cacheKeys: invalidationKeys,
taskIds: [...invalidationTaskIds],
};
}
function buildTaskChangeWarmRequests(
teamName: string,
tasks: TeamData['tasks']
): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] {
return tasks.flatMap((task) => {
const options = buildTaskChangeRequestOptions(task);
if (!isTaskSummaryCacheableForOptions(options)) {
return [];
}
return [{ teamName, taskId: task.id, options }];
});
}
function mapSendMessageError(error: unknown): string {
const message =
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
@ -770,6 +817,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (get().selectedTeamLoading && get().selectedTeamName === teamName) {
return;
}
const previousSelectedTeamName = get().selectedTeamName;
const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null;
// Stale-while-revalidate: keep previous data visible while loading new team.
// Skeleton only shows on first load (when data is null).
@ -817,6 +866,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamLoading: false,
selectedTeamError: null,
});
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
if (invalidationState.cacheKeys.length > 0) {
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
}
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks);
if (warmRequests.length > 0) {
void get().warmTaskChangeSummaries(warmRequests);
}
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
@ -902,6 +964,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load).
try {
const previousData = get().selectedTeamData;
const data = await withTimeout(
unwrapIpc('team:getData', () => api.teams.getData(teamName)),
TEAM_GET_DATA_TIMEOUT_MS,
@ -915,6 +978,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamData: data,
selectedTeamError: null,
});
const invalidationState = previousData
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
: { cacheKeys: [], taskIds: [] };
if (invalidationState.cacheKeys.length > 0) {
get().invalidateTaskChangePresence(invalidationState.cacheKeys);
}
if (invalidationState.taskIds.length > 0) {
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
}
const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks);
if (warmRequests.length > 0) {
void get().warmTaskChangeSummaries(warmRequests);
}
} catch (error) {
if (get().selectedTeamName !== teamName) {
return;

View file

@ -1,5 +1,10 @@
import type { ReviewAPI } from '@shared/types/api';
import type { TeamTaskWithKanban } from '@shared/types/team';
import {
getTaskChangeStateBucket,
isTaskChangeSummaryCacheable,
type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState';
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
@ -13,7 +18,15 @@ export interface TaskChangeContext {
type TaskChangeTaskLike = Pick<
TeamTaskWithKanban,
'id' | 'owner' | 'status' | 'createdAt' | 'updatedAt' | 'workIntervals' | 'historyEvents'
| 'id'
| 'owner'
| 'status'
| 'createdAt'
| 'updatedAt'
| 'workIntervals'
| 'historyEvents'
| 'reviewState'
| 'kanbanColumn'
>;
export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined {
@ -48,6 +61,7 @@ export function buildTaskChangeRequestOptions(
status: task.status,
intervals: task.workIntervals,
since: deriveTaskSince(task),
stateBucket: getTaskChangeStateBucket(task),
};
return {
@ -73,6 +87,7 @@ export function buildTaskChangeSignature(options: TaskChangeRequestOptions): str
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 stateBucket = typeof options.stateBucket === 'string' ? options.stateBucket : 'active';
const intervals = Array.isArray(options.intervals)
? options.intervals.map((interval) => ({
startedAt: interval.startedAt,
@ -84,6 +99,7 @@ export function buildTaskChangeSignature(options: TaskChangeRequestOptions): str
owner,
status,
since,
stateBucket,
intervals,
});
}
@ -95,3 +111,22 @@ export function buildTaskChangePresenceKey(
): string {
return `${teamName}:${taskId}:${buildTaskChangeSignature(options)}`;
}
export function getTaskChangeStateBucketFromOptions(
options: TaskChangeRequestOptions | null | undefined
): TaskChangeStateBucket {
switch (options?.stateBucket) {
case 'approved':
case 'review':
case 'completed':
return options.stateBucket;
default:
return 'active';
}
}
export function isTaskSummaryCacheableForOptions(
options: TaskChangeRequestOptions | null | undefined
): boolean {
return isTaskChangeSummaryCacheable(getTaskChangeStateBucketFromOptions(options));
}

View file

@ -595,10 +595,15 @@ export interface ReviewAPI {
intervals?: { startedAt: string; completedAt?: string }[];
/** Back-compat: single since timestamp (deprecated). */
since?: string;
/** Derived task lifecycle bucket used for safe summary caching. */
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
/** Lightweight response for summary UIs; skips snippets/timeline details. */
summaryOnly?: boolean;
/** Force a fresh recompute and overwrite any cache snapshot. */
forceFresh?: boolean;
}
) => Promise<TaskChangeSetV2>;
invalidateTaskChangeSummaries: (teamName: string, taskIds: string[]) => Promise<void>;
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
getFileContent: (
teamName: string,

View file

@ -0,0 +1,48 @@
import type { TaskHistoryEvent, TeamReviewState } from '@shared/types';
import { getDerivedReviewState } from './taskHistory';
export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
interface TaskChangeStateLike {
status?: string | null;
reviewState?: TeamReviewState | null;
historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved' | null;
}
function normalizeReviewState(value: unknown): TeamReviewState {
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
}
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] });
}
const explicit = normalizeReviewState(task.reviewState);
if (explicit !== 'none') {
return explicit;
}
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
}
return 'none';
}
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
const reviewState = getEffectiveReviewState(task);
if (reviewState === 'approved') return 'approved';
if (reviewState === 'review') return 'review';
return task.status === 'completed' ? 'completed' : 'active';
}
export function isTaskChangeSummaryCacheable(
taskOrBucket: TaskChangeStateLike | TaskChangeStateBucket
): boolean {
const bucket =
typeof taskOrBucket === 'string' ? taskOrBucket : getTaskChangeStateBucket(taskOrBucket);
return bucket === 'completed' || bucket === 'approved';
}

View file

@ -7,6 +7,97 @@ import * as fs from 'fs/promises';
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
const TEAM_NAME = 'team-a';
const TASK_ID = '1';
const PROJECT_PATH = '/repo';
const SUMMARY_OPTIONS = {
owner: 'alice',
status: 'completed',
stateBucket: 'completed' as const,
summaryOnly: true,
};
function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) {
return {
timestamp,
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Write',
input: { file_path: filePath, content },
},
],
},
};
}
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
await fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
}
async function writeTaskFile(
baseDir: string,
overrides?: Record<string, unknown>
): Promise<string> {
const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${TASK_ID}.json`);
await fs.mkdir(path.dirname(taskPath), { recursive: true });
await fs.writeFile(
taskPath,
JSON.stringify(
{
id: TASK_ID,
owner: 'alice',
status: 'completed',
createdAt: '2026-03-01T09:55:00.000Z',
updatedAt: '2026-03-01T10:10:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
historyEvents: [],
...overrides,
},
null,
2
),
'utf8'
);
return taskPath;
}
function persistedEntryPath(baseDir: string): string {
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
}
function createService(params: {
logPaths: string[];
projectPath?: string;
findLogsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
}) {
const findLogsForTask =
params.findLogsForTask ??
vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' })));
return {
findLogsForTask,
service: new ChangeExtractorService(
{
findLogsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any
),
};
}
describe('ChangeExtractorService', () => {
let tmpDir: string | null = null;
@ -23,46 +114,17 @@ describe('ChangeExtractorService', () => {
setClaudeBasePathOverride(tmpDir);
const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
await fs.writeFile(
aliceLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'Write',
input: { file_path: '/repo/src/file.ts', content: 'export const value = 1;\n' },
},
],
},
}) + '\n',
'utf8'
);
await writeJsonl(aliceLogPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const findLogsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
);
const parseBoundaries = vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}));
const service = new ChangeExtractorService(
{
findLogsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{ parseBoundaries } as any,
{ getConfig: vi.fn(async () => ({ projectPath: '/repo' })) } as any
);
const service = createService({ logPaths: [aliceLogPath], findLogsForTask }).service;
const empty = await service.getTaskChanges('team-a', '1', { owner: 'bob', status: 'completed' });
const populated = await service.getTaskChanges('team-a', '1', {
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' });
const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});
@ -72,71 +134,235 @@ describe('ChangeExtractorService', () => {
expect(findLogsForTask).toHaveBeenCalledTimes(2);
});
it('merges fallback changes for the same Windows file across slash variants', async () => {
it('caches terminal summary requests in memory but keeps detailed requests fresh', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
const firstLogPath = path.join(tmpDir, 'first.jsonl');
const secondLogPath = path.join(tmpDir, 'second.jsonl');
await fs.writeFile(
firstLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'Write',
input: { file_path: 'C:\\repo\\src\\same.ts', content: 'first\n' },
},
],
},
}) + '\n',
'utf8'
const logPath = path.join(tmpDir, 'alice-summary.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const { service, findLogsForTask } = createService({ logPaths: [logPath] });
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
stateBucket: 'completed',
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
stateBucket: 'completed',
});
expect(findLogsForTask).toHaveBeenCalledTimes(3);
});
it('restores a persisted terminal summary after a simulated restart', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-restart.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const first = createService({ logPaths: [logPath] });
const initial = await first.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
const second = createService({ logPaths: [logPath] });
const restored = await second.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(initial.files).toHaveLength(1);
expect(restored.files).toHaveLength(1);
expect(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')).toContain('"taskId": "1"');
expect((second.findLogsForTask as any).mock.calls).toHaveLength(0);
});
it('forceFresh overwrites the persisted terminal summary snapshot', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const { service } = createService({ logPaths: [logPath] });
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'),
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'),
]);
const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
...SUMMARY_OPTIONS,
forceFresh: true,
});
const after = await createService({ logPaths: [logPath] }).service.getTaskChanges(
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
await fs.writeFile(
secondLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:01:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'Write',
input: { file_path: 'C:/repo/src/same.ts', content: 'second\n' },
},
],
expect(refreshed.totalFiles).toBe(2);
expect(after.totalFiles).toBe(2);
});
it('invalidates old terminal summaries when the task moves into review', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-review.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const { service } = createService({ logPaths: [logPath] });
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await writeTaskFile(tmpDir, {
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
to: 'review',
timestamp: '2026-03-01T11:00:00.000Z',
},
}) + '\n',
'utf8'
],
});
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
stateBucket: 'review',
summaryOnly: true,
});
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rejects persisted summaries after project/worktree drift', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
await drifted.service.getTaskChanges(
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
expect((drifted.findLogsForTask as any).mock.calls.length).toBeGreaterThan(1);
});
it('rejects persisted summaries when the task file is missing on restart', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
const taskPath = await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await fs.unlink(taskPath);
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('falls back safely when the persisted summary file is corrupted', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
vi.spyOn(console, 'warn').mockImplementation(() => {});
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
const restored = await createService({ logPaths: [logPath] }).service.getTaskChanges(
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
expect(restored.files).toHaveLength(1);
});
it('does not persist low-confidence fallback summaries', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir, { workIntervals: [], historyEvents: [] });
const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
]);
const service = new ChangeExtractorService(
{
findLogsForTask: vi.fn(async () => [
{ filePath: firstLogPath, memberName: 'alice' },
{ filePath: secondLogPath, memberName: 'alice' },
]),
findLogsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
isSingleTaskSession: false,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: 'C:\\repo' })) } as any
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any
);
const result = await service.getTaskChanges('team-a', '1', {
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(result.confidence).toBe('fallback');
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('merges fallback changes for the same Windows file across slash variants', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
const firstLogPath = path.join(tmpDir, 'first.jsonl');
const secondLogPath = path.join(tmpDir, 'second.jsonl');
await writeJsonl(firstLogPath, [
buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'),
]);
await writeJsonl(secondLogPath, [
buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'),
]);
const service = createService({
logPaths: [firstLogPath, secondLogPath],
projectPath: 'C:\\repo',
}).service;
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice',
status: 'completed',
});

View file

@ -0,0 +1,135 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
function buildEntry(overrides?: Partial<PersistedTaskChangeSummaryEntry>): PersistedTaskChangeSummaryEntry {
return {
version: 1,
teamName: 'team-a',
taskId: '1',
stateBucket: 'completed',
taskSignature: '{"owner":"alice"}',
sourceFingerprint: 'source-fingerprint',
projectFingerprint: 'project-fingerprint',
writtenAt: '2026-03-01T10:00:00.000Z',
expiresAt: '2099-03-01T10:00:00.000Z',
extractorConfidence: 'high',
summary: {
teamName: 'team-a',
taskId: '1',
files: [
{
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
snippets: [
{
toolUseId: 'tool-1',
filePath: '/repo/src/file.ts',
toolName: 'Write',
type: 'write-new',
oldString: '',
newString: 'x',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
},
],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'high',
computedAt: '2026-03-01T10:00:00.000Z',
scope: {
taskId: '1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/src/file.ts'],
confidence: { tier: 1, label: 'high', reason: 'test' },
},
warnings: [],
},
...overrides,
};
}
describe('JsonTaskChangeSummaryCacheRepository', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('saves and loads normalized per-task entries', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
await repo.save(buildEntry());
const loaded = await repo.load('team-a', '1');
expect(loaded?.summary.files[0]?.snippets).toEqual([]);
expect(
await fs.readFile(
path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json'),
'utf8'
)
).toContain('"teamName": "team-a"');
});
it('treats expired entries as cache misses', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
await repo.save(buildEntry({ expiresAt: '2000-03-01T10:00:00.000Z' }));
expect(await repo.load('team-a', '1')).toBeNull();
});
it('ignores malformed entries and deletes them best-effort', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
vi.spyOn(console, 'warn').mockImplementation(() => {});
const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{bad-json', 'utf8');
expect(await repo.load('team-a', '1')).toBeNull();
await expect(fs.stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('does not let older generations overwrite newer ones', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
const newer = await repo.save(buildEntry({ taskSignature: 'newer' }), { generation: 2 });
const older = await repo.save(buildEntry({ taskSignature: 'older' }), { generation: 1 });
const loaded = await repo.load('team-a', '1');
expect(newer.written).toBe(true);
expect(older.written).toBe(false);
expect(loaded?.taskSignature).toBe('newer');
});
});

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import { createChangeReviewSlice } from '../../../src/renderer/store/slices/changeReviewSlice';
import { buildTaskChangePresenceKey } from '../../../src/renderer/utils/taskChangeRequest';
const hoisted = vi.hoisted(() => ({
getTaskChanges: vi.fn(),
@ -49,11 +50,17 @@ function deferred<T>() {
return { promise, resolve, reject };
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
const OPTIONS_A = {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
since: '2026-03-01T09:58:00.000Z',
stateBucket: 'completed' as const,
};
const OPTIONS_B = {
@ -61,6 +68,15 @@ const OPTIONS_B = {
status: 'completed',
intervals: [{ startedAt: '2026-03-01T11:00:00.000Z' }],
since: '2026-03-01T10:58:00.000Z',
stateBucket: 'completed' as const,
};
const REVIEW_OPTIONS = {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
since: '2026-03-01T09:58:00.000Z',
stateBucket: 'review' as const,
};
describe('changeReviewSlice task changes', () => {
@ -170,4 +186,210 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().activeChangeSet?.taskId).toBe('2');
expect(store.getState().selectedReviewFilePath).toBe('/repo/new.ts');
});
it('does not treat review-state summaries as permanently cacheable', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: '1',
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS);
await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('re-warms terminal summaries after an earlier empty result', async () => {
const store = createSliceStore();
const teamName = 'team-warm';
const taskId = 'late-log-task';
hoisted.getTaskChanges
.mockResolvedValueOnce({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName,
taskId,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
})
.mockResolvedValueOnce({
teamName,
taskId,
files: [
{
filePath: '/repo/new.ts',
relativePath: 'new.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:01:00.000Z',
scope: {
taskId: '1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/new.ts'],
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: [],
})
.mockResolvedValueOnce({
teamName,
taskId,
files: [
{
filePath: '/repo/new.ts',
relativePath: 'new.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:01:01.000Z',
scope: {
taskId: '1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/new.ts'],
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
await store
.getState()
.warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3);
expect(
store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]
).toBe(true);
});
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
const store = createSliceStore();
const teamName = 'team-revalidate';
const taskId = 'persisted-hit';
hoisted.getTaskChanges
.mockResolvedValueOnce({
teamName,
taskId,
files: [
{
filePath: '/repo/persisted.ts',
relativePath: 'persisted.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'medium',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/persisted.ts'],
confidence: { tier: 2, label: 'medium', reason: 'Persisted summary' },
},
warnings: [],
})
.mockResolvedValueOnce({
teamName,
taskId,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:01:00.000Z',
scope: {
taskId: '1',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
await flushAsyncWork();
expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(1, teamName, taskId, {
...OPTIONS_A,
summaryOnly: true,
});
expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(2, teamName, taskId, {
...OPTIONS_A,
summaryOnly: true,
forceFresh: true,
});
expect(store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]).toBe(false);
});
});

View file

@ -12,6 +12,7 @@ const hoisted = vi.hoisted(() => ({
sendMessage: vi.fn(),
requestReview: vi.fn(),
updateKanban: vi.fn(),
invalidateTaskChangeSummaries: vi.fn(),
onProvisioningProgress: vi.fn(() => () => undefined),
}));
@ -28,6 +29,9 @@ vi.mock('@renderer/api', () => ({
updateKanban: hoisted.updateKanban,
onProvisioningProgress: hoisted.onProvisioningProgress,
},
review: {
invalidateTaskChangeSummaries: hoisted.invalidateTaskChangeSummaries,
},
},
}));
@ -63,6 +67,8 @@ function createSliceStore() {
openTab: vi.fn(),
setActiveTab: vi.fn(),
getAllPaneTabs: vi.fn(() => []),
warmTaskChangeSummaries: vi.fn(async () => undefined),
invalidateTaskChangePresence: vi.fn(),
}));
}
@ -82,6 +88,7 @@ describe('teamSlice actions', () => {
hoisted.requestReview.mockResolvedValue(undefined);
hoisted.updateKanban.mockResolvedValue(undefined);
hoisted.createTeam.mockResolvedValue({ runId: 'run-1' });
hoisted.invalidateTaskChangeSummaries.mockResolvedValue(undefined);
hoisted.getProvisioningStatus.mockResolvedValue({
runId: 'run-1',
teamName: 'my-team',
@ -251,5 +258,112 @@ describe('teamSlice actions', () => {
// No previous data — error should be shown
expect(store.getState().selectedTeamError).toBe('Team not found');
});
it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => {
const store = createSliceStore();
const invalidateTaskChangePresence = vi.fn();
const warmTaskChangeSummaries = vi.fn(async () => undefined);
store.setState({
selectedTeamName: 'my-team',
invalidateTaskChangePresence,
warmTaskChangeSummaries,
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Old completed',
status: 'completed',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
},
{
id: 'task-2',
subject: 'Still approved',
status: 'completed',
owner: 'bob',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-approved',
type: 'review_approved',
to: 'approved',
timestamp: '2026-03-01T10:10:00.000Z',
},
],
comments: [],
attachments: [],
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
},
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Moved to review',
status: 'completed',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T11:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
to: 'review',
timestamp: '2026-03-01T11:00:00.000Z',
},
],
comments: [],
attachments: [],
},
{
id: 'task-2',
subject: 'Still approved',
status: 'completed',
owner: 'bob',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [
{
id: 'evt-approved',
type: 'review_approved',
to: 'approved',
timestamp: '2026-03-01T10:10:00.000Z',
},
],
comments: [],
attachments: [],
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
});
await store.getState().refreshTeamData('my-team');
expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']);
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
expect(warmTaskChangeSummaries).toHaveBeenCalledWith([
expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }),
]);
});
});
});

View file

@ -48,6 +48,7 @@ describe('taskChangeRequest', () => {
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
since: '2026-03-01T10:03:00.000Z',
stateBucket: 'completed',
summaryOnly: true,
});
});
@ -58,10 +59,14 @@ describe('taskChangeRequest', () => {
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
since: '2026-03-01T10:03:00.000Z',
stateBucket: 'completed' as const,
};
expect(buildTaskChangePresenceKey('team-a', '1', base)).not.toBe(
buildTaskChangePresenceKey('team-a', '1', { ...base, owner: 'bob' })
);
expect(buildTaskChangePresenceKey('team-a', '1', base)).not.toBe(
buildTaskChangePresenceKey('team-a', '1', { ...base, stateBucket: 'review' })
);
});
});