Merge branch 'dev' into feature/extensions-skills
This commit is contained in:
commit
5abd8a0ceb
23 changed files with 2984 additions and 1114 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts
vendored
Normal file
183
src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts
vendored
Normal file
11
src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts
vendored
Normal 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>;
|
||||
}
|
||||
159
src/main/services/team/cache/taskChangeSummaryCacheSchema.ts
vendored
Normal file
159
src/main/services/team/cache/taskChangeSummaryCacheSchema.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
27
src/main/services/team/cache/taskChangeSummaryCacheTypes.ts
vendored
Normal file
27
src/main/services/team/cache/taskChangeSummaryCacheTypes.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
48
src/shared/utils/taskChangeState.ts
Normal file
48
src/shared/utils/taskChangeState.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue