improvement(task-change): improve task change presence tracking and related IPC handlers
- Added support for tracking task change presence with new IPC channels: TEAM_GET_TASK_CHANGE_PRESENCE and TEAM_SET_CHANGE_PRESENCE_TRACKING. - Introduced JsonTaskChangePresenceRepository and TeamLogSourceTracker to manage task change presence data. - Enhanced ChangeExtractorService to utilize task change presence services for improved task change detection. - Updated TeamDataService to integrate task change presence tracking and resolve task change presence states. - Modified UI components to reflect task change presence status in Kanban and task detail views. This feature aims to provide real-time insights into task changes, enhancing user experience and task management capabilities.
This commit is contained in:
parent
fe90ac866d
commit
507bf798eb
37 changed files with 4362 additions and 1136 deletions
|
|
@ -76,7 +76,8 @@ export default defineConfig({
|
|||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts')
|
||||
'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'),
|
||||
'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts')
|
||||
},
|
||||
output: {
|
||||
// CJS format so bundled deps can use __dirname/require.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
|||
import { TeamBackupService } from '@main/services/team/TeamBackupService';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import {
|
||||
CONTEXT_CHANGED,
|
||||
|
|
@ -104,6 +105,7 @@ import {
|
|||
TaskBoundaryParser,
|
||||
TeamDataService,
|
||||
TeamMemberLogsFinder,
|
||||
TeamLogSourceTracker,
|
||||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
|
|
@ -780,9 +782,13 @@ function initializeServices(): void {
|
|||
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||
const taskBoundaryParser = new TaskBoundaryParser();
|
||||
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
|
||||
teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
|
||||
changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker);
|
||||
const gitDiffFallback = new GitDiffFallback();
|
||||
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
|
||||
const reviewApplier = new ReviewApplierService();
|
||||
|
|
@ -839,6 +845,7 @@ function initializeServices(): void {
|
|||
httpServer?.broadcast('team-change', event);
|
||||
};
|
||||
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||
teamLogSourceTracker.setEmitter(teamChangeEmitter);
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
schedulerService.setChangeEmitter((event) => {
|
||||
|
|
@ -1321,7 +1328,9 @@ function createWindow(): void {
|
|||
markRendererUnavailable(mainWindow);
|
||||
const activeContext = contextRegistry.getActive();
|
||||
activeContext?.stopFileWatcher();
|
||||
scheduleRendererRecovery(mainWindow);
|
||||
if (mainWindow) {
|
||||
scheduleRendererRecovery(mainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
// Set main window reference for notification manager and updater
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_TASK_CHANGE_PRESENCE,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
|
|
@ -46,6 +47,7 @@ import {
|
|||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -306,6 +308,8 @@ export function initializeTeamHandlers(
|
|||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
||||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||
|
|
@ -368,6 +372,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TEAM_LIST);
|
||||
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_CREATE);
|
||||
|
|
@ -613,6 +619,38 @@ async function handleGetData(
|
|||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
}
|
||||
|
||||
async function handleGetTaskChangePresence(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<Record<string, 'has_changes' | 'no_changes' | 'unknown'>>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('getTaskChangePresence', () =>
|
||||
getTeamDataService().getTaskChangePresence(validated.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSetChangePresenceTracking(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
enabled: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'enabled must be a boolean' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('setChangePresenceTracking', async () => {
|
||||
getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
706
src/main/services/team/TaskChangeComputer.ts
Normal file
706
src/main/services/team/TaskChangeComputer.ts
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { countLineChanges } from './UnifiedLineCounter';
|
||||
import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils';
|
||||
|
||||
import type { TaskBoundaryParser } from './TaskBoundaryParser';
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type {
|
||||
AgentChangeSet,
|
||||
FileChangeSummary,
|
||||
FileEditEvent,
|
||||
FileEditTimeline,
|
||||
SnippetDiff,
|
||||
TaskChangeScope,
|
||||
TaskChangeSetV2,
|
||||
} from '@shared/types';
|
||||
import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes';
|
||||
|
||||
const logger = createLogger('Service:TaskChangeComputer');
|
||||
|
||||
interface ParsedSnippetsCacheEntry {
|
||||
data: SnippetDiff[];
|
||||
mtime: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface LogFileRef {
|
||||
filePath: string;
|
||||
memberName: string;
|
||||
}
|
||||
|
||||
export class TaskChangeComputer {
|
||||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||||
private readonly parsedSnippetsCacheTtl = 20 * 1000;
|
||||
private static readonly JSONL_PARSE_CONCURRENCY = 6;
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly boundaryParser: TaskBoundaryParser
|
||||
) {}
|
||||
|
||||
async computeAgentChanges(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
projectPath?: string
|
||||
): Promise<{ result: AgentChangeSet; latestMtime: number }> {
|
||||
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
|
||||
const parseResults = await this.parseJSONLFilesWithConcurrency(paths);
|
||||
let latestMtime = 0;
|
||||
const merged: SnippetDiff[] = [];
|
||||
|
||||
for (const result of parseResults) {
|
||||
merged.push(...result.snippets);
|
||||
if (result.mtime > latestMtime) {
|
||||
latestMtime = result.mtime;
|
||||
}
|
||||
}
|
||||
|
||||
const files = this.aggregateByFile(this.sortSnippetsChronologically(merged), projectPath);
|
||||
const taskChangeResult = {
|
||||
teamName,
|
||||
memberName,
|
||||
files,
|
||||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
||||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||||
totalFiles: files.length,
|
||||
computedAt: new Date().toISOString(),
|
||||
} satisfies AgentChangeSet;
|
||||
|
||||
return { result: taskChangeResult, latestMtime };
|
||||
}
|
||||
|
||||
async computeTaskChanges(input: ResolvedTaskChangeComputeInput): Promise<TaskChangeSetV2> {
|
||||
const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input;
|
||||
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
||||
teamName,
|
||||
taskId,
|
||||
effectiveOptions
|
||||
);
|
||||
if (logRefs.length === 0) {
|
||||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
}
|
||||
|
||||
const allScopes: TaskChangeScope[] = [];
|
||||
for (const ref of logRefs) {
|
||||
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
|
||||
const scope = boundaries.scopes.find((candidate) => candidate.taskId === taskId);
|
||||
if (scope) {
|
||||
allScopes.push({ ...scope, memberName: ref.memberName });
|
||||
}
|
||||
}
|
||||
|
||||
if (allScopes.length === 0) {
|
||||
const intervals = effectiveOptions.intervals;
|
||||
if (Array.isArray(intervals) && intervals.length > 0) {
|
||||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
files,
|
||||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
||||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||||
totalFiles: files.length,
|
||||
confidence: 'medium',
|
||||
computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
toolUseIds,
|
||||
filePaths: files.map((file) => file.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.'],
|
||||
};
|
||||
}
|
||||
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
|
||||
}
|
||||
|
||||
const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds));
|
||||
const files = await this.extractFilteredChanges(
|
||||
logRefs,
|
||||
allowedToolUseIds,
|
||||
projectPath,
|
||||
includeDetails
|
||||
);
|
||||
|
||||
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
files,
|
||||
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: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
private async extractIntervalScopedChanges(
|
||||
logRefs: LogFileRef[],
|
||||
intervals: { startedAt: string; completedAt?: string }[],
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<{
|
||||
files: FileChangeSummary[];
|
||||
toolUseIds: string[];
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
}> {
|
||||
const normalized: {
|
||||
startMs: number;
|
||||
endMs: number | null;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}[] = [];
|
||||
|
||||
for (const interval of intervals) {
|
||||
const startMs = Date.parse(interval.startedAt);
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
const endMsRaw =
|
||||
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
|
||||
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
|
||||
normalized.push({
|
||||
startMs,
|
||||
endMs,
|
||||
startedAt: interval.startedAt,
|
||||
completedAt: interval.completedAt,
|
||||
});
|
||||
}
|
||||
|
||||
normalized.sort((a, b) => a.startMs - b.startMs);
|
||||
const startTimestamp = normalized[0]?.startedAt ?? '';
|
||||
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>(
|
||||
(acc, item) => {
|
||||
if (item.endMs == null || typeof item.completedAt !== 'string') return acc;
|
||||
if (!acc || item.endMs > acc.endMs) {
|
||||
return { endMs: item.endMs, endTimestamp: item.completedAt };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
null
|
||||
);
|
||||
const endTimestamp = maxEnd?.endTimestamp ?? '';
|
||||
|
||||
const inAnyInterval = (timestamp: string): boolean => {
|
||||
const ms = Date.parse(timestamp);
|
||||
if (!Number.isFinite(ms)) return false;
|
||||
for (const interval of normalized) {
|
||||
if (ms < interval.startMs) continue;
|
||||
if (interval.endMs == null) return true;
|
||||
if (ms <= interval.endMs) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||
const allowedSnippets: SnippetDiff[] = [];
|
||||
const toolUseIdsSet = new Set<string>();
|
||||
|
||||
for (const { snippets } of allParsed) {
|
||||
for (const snippet of snippets) {
|
||||
if (snippet.isError) continue;
|
||||
if (!inAnyInterval(snippet.timestamp)) continue;
|
||||
allowedSnippets.push(snippet);
|
||||
if (snippet.toolUseId) {
|
||||
toolUseIdsSet.add(snippet.toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files: this.aggregateByFile(
|
||||
this.sortSnippetsChronologically(allowedSnippets),
|
||||
projectPath,
|
||||
includeDetails
|
||||
),
|
||||
toolUseIds: [...toolUseIdsSet],
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private async extractFilteredChanges(
|
||||
logRefs: LogFileRef[],
|
||||
allowedToolUseIds: Set<string>,
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||
const allSnippets: SnippetDiff[] = [];
|
||||
|
||||
for (const { snippets } of allParsed) {
|
||||
if (allowedToolUseIds.size > 0) {
|
||||
for (const snippet of snippets) {
|
||||
if (allowedToolUseIds.has(snippet.toolUseId)) {
|
||||
allSnippets.push(snippet);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allSnippets.push(...snippets);
|
||||
}
|
||||
}
|
||||
|
||||
return this.aggregateByFile(
|
||||
this.sortSnippetsChronologically(allSnippets),
|
||||
projectPath,
|
||||
includeDetails
|
||||
);
|
||||
}
|
||||
|
||||
private async fallbackSingleTaskScope(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
logRefs: LogFileRef[],
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<TaskChangeSetV2> {
|
||||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||
const allSnippets = this.sortSnippetsChronologically(
|
||||
allParsed.flatMap((result) => result.snippets)
|
||||
);
|
||||
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
files: aggregatedFiles,
|
||||
totalLinesAdded: aggregatedFiles.reduce((sum, file) => sum + file.linesAdded, 0),
|
||||
totalLinesRemoved: aggregatedFiles.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||||
totalFiles: aggregatedFiles.length,
|
||||
confidence: 'fallback',
|
||||
computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: logRefs[0]?.memberName ?? 'unknown',
|
||||
startLine: 1,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: aggregatedFiles.map((file) => file.filePath),
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
|
||||
},
|
||||
warnings: ['No task boundaries found — showing all changes from related sessions.'],
|
||||
};
|
||||
}
|
||||
|
||||
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
files: [],
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
totalFiles: 0,
|
||||
confidence: 'fallback',
|
||||
computedAt: new Date().toISOString(),
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
||||
},
|
||||
warnings: ['No log files found for this task.'],
|
||||
};
|
||||
}
|
||||
|
||||
private async parseJSONLFilesWithConcurrency(
|
||||
paths: string[]
|
||||
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex++;
|
||||
if (currentIndex >= paths.length) return;
|
||||
results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) },
|
||||
() => worker()
|
||||
)
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async parseJSONLFile(
|
||||
filePath: string
|
||||
): Promise<{ snippets: SnippetDiff[]; mtime: number }> {
|
||||
let fileMtime = 0;
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
fileMtime = fileStat.mtimeMs;
|
||||
const cached = this.parsedSnippetsCache.get(filePath);
|
||||
if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) {
|
||||
return { snippets: cached.data, mtime: fileMtime };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`);
|
||||
return { snippets: [], mtime: 0 };
|
||||
}
|
||||
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
|
||||
} catch {
|
||||
// Ignore invalid JSON lines.
|
||||
}
|
||||
}
|
||||
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch (error) {
|
||||
logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`);
|
||||
return { snippets: [], mtime: 0 };
|
||||
}
|
||||
|
||||
const erroredIds = this.collectErroredToolUseIds(entries);
|
||||
const snippets: SnippetDiff[] = [];
|
||||
const seenFiles = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const role = this.extractRole(entry);
|
||||
if (role !== 'assistant') continue;
|
||||
|
||||
const content = this.extractContent(entry);
|
||||
if (!content) continue;
|
||||
|
||||
const timestamp =
|
||||
typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString();
|
||||
|
||||
for (const block of content) {
|
||||
if (
|
||||
!block ||
|
||||
typeof block !== 'object' ||
|
||||
(block as Record<string, unknown>).type !== 'tool_use'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolBlock = block as Record<string, unknown>;
|
||||
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : '';
|
||||
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
|
||||
const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : '';
|
||||
const input = toolBlock.input as Record<string, unknown> | undefined;
|
||||
if (!input) continue;
|
||||
|
||||
const isError = erroredIds.has(toolUseId);
|
||||
|
||||
if (toolName === 'Edit') {
|
||||
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
||||
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
||||
const replaceAll = input.replace_all === true;
|
||||
|
||||
if (targetPath) {
|
||||
seenFiles.add(this.normalizeFilePathKey(targetPath));
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: targetPath,
|
||||
toolName: 'Edit',
|
||||
type: 'edit',
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll,
|
||||
timestamp,
|
||||
isError,
|
||||
contextHash: this.computeContextHash(oldString, newString),
|
||||
});
|
||||
}
|
||||
} else if (toolName === 'Write') {
|
||||
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||
const writeContent = typeof input.content === 'string' ? input.content : '';
|
||||
|
||||
if (targetPath) {
|
||||
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
|
||||
const isNew = !seenFiles.has(normalizedTargetPath);
|
||||
seenFiles.add(normalizedTargetPath);
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: targetPath,
|
||||
toolName: 'Write',
|
||||
type: isNew ? 'write-new' : 'write-update',
|
||||
oldString: '',
|
||||
newString: writeContent,
|
||||
replaceAll: false,
|
||||
timestamp,
|
||||
isError,
|
||||
contextHash: this.computeContextHash('', writeContent),
|
||||
});
|
||||
}
|
||||
} else if (toolName === 'MultiEdit') {
|
||||
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||
const edits = Array.isArray(input.edits) ? input.edits : [];
|
||||
|
||||
if (targetPath) {
|
||||
seenFiles.add(this.normalizeFilePathKey(targetPath));
|
||||
for (const edit of edits) {
|
||||
if (!edit || typeof edit !== 'object') continue;
|
||||
const editObj = edit as Record<string, unknown>;
|
||||
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
|
||||
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: targetPath,
|
||||
toolName: 'MultiEdit',
|
||||
type: 'multi-edit',
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll: false,
|
||||
timestamp,
|
||||
isError,
|
||||
contextHash: this.computeContextHash(oldString, newString),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.parsedSnippetsCache.set(filePath, {
|
||||
data: snippets,
|
||||
mtime: fileMtime,
|
||||
expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
|
||||
});
|
||||
|
||||
return { snippets, mtime: fileMtime };
|
||||
}
|
||||
|
||||
private extractContent(entry: Record<string, unknown>): unknown[] | null {
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && Array.isArray(message.content)) return message.content as unknown[];
|
||||
if (Array.isArray(entry.content)) return entry.content as unknown[];
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractRole(entry: Record<string, unknown>): string | null {
|
||||
if (typeof entry.role === 'string') return entry.role;
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && typeof message.role === 'string') return message.role;
|
||||
return null;
|
||||
}
|
||||
|
||||
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
|
||||
const erroredIds = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (Array.isArray(entry.content)) {
|
||||
for (const block of entry.content) {
|
||||
if (this.isErroredToolResult(block)) {
|
||||
const toolUseId = (block as Record<string, unknown>).tool_use_id;
|
||||
if (typeof toolUseId === 'string') {
|
||||
erroredIds.add(toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (this.isErroredToolResult(block)) {
|
||||
const toolUseId = (block as Record<string, unknown>).tool_use_id;
|
||||
if (typeof toolUseId === 'string') {
|
||||
erroredIds.add(toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return erroredIds;
|
||||
}
|
||||
|
||||
private isErroredToolResult(block: unknown): boolean {
|
||||
if (!block || typeof block !== 'object') return false;
|
||||
const obj = block as Record<string, unknown>;
|
||||
return obj.type === 'tool_result' && obj.is_error === true;
|
||||
}
|
||||
|
||||
private aggregateByFile(
|
||||
snippets: SnippetDiff[],
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): FileChangeSummary[] {
|
||||
const fileMap = new Map<
|
||||
string,
|
||||
{ filePath: string; snippets: SnippetDiff[]; isNewFile: boolean }
|
||||
>();
|
||||
|
||||
for (const snippet of snippets) {
|
||||
if (snippet.isError) continue;
|
||||
|
||||
const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath);
|
||||
const existing = fileMap.get(normalizedFilePath);
|
||||
if (existing) {
|
||||
existing.snippets.push(snippet);
|
||||
if (snippet.type === 'write-new') existing.isNewFile = true;
|
||||
} else {
|
||||
fileMap.set(normalizedFilePath, {
|
||||
filePath: snippet.filePath,
|
||||
snippets: [snippet],
|
||||
isNewFile: snippet.type === 'write-new',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...fileMap.values()].map((data) => {
|
||||
let totalAdded = 0;
|
||||
let totalRemoved = 0;
|
||||
for (const snippet of data.snippets) {
|
||||
if (snippet.isError) continue;
|
||||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||||
totalAdded += added;
|
||||
totalRemoved += removed;
|
||||
}
|
||||
|
||||
const normalizedFilePath = data.filePath.replace(/\\/g, '/');
|
||||
const normalizedProjectPath = projectPath?.replace(/\\/g, '/');
|
||||
const relativePath = normalizedProjectPath
|
||||
? normalizedFilePath.startsWith(normalizedProjectPath + '/')
|
||||
? normalizedFilePath.slice(normalizedProjectPath.length + 1)
|
||||
: normalizedFilePath.startsWith(normalizedProjectPath)
|
||||
? normalizedFilePath.slice(normalizedProjectPath.length)
|
||||
: normalizedFilePath.split('/').slice(-3).join('/')
|
||||
: normalizedFilePath.split('/').slice(-3).join('/');
|
||||
|
||||
return {
|
||||
filePath: data.filePath,
|
||||
relativePath,
|
||||
snippets: includeDetails ? data.snippets : [],
|
||||
linesAdded: totalAdded,
|
||||
linesRemoved: totalRemoved,
|
||||
isNewFile: data.isNewFile,
|
||||
timeline: includeDetails ? this.buildTimeline(data.filePath, data.snippets) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
|
||||
const events: FileEditEvent[] = snippets
|
||||
.filter((snippet) => !snippet.isError)
|
||||
.map((snippet, index) => {
|
||||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||||
return {
|
||||
toolUseId: snippet.toolUseId,
|
||||
toolName: snippet.toolName as FileEditEvent['toolName'],
|
||||
timestamp: snippet.timestamp,
|
||||
summary: this.generateEditSummary(snippet),
|
||||
linesAdded: added,
|
||||
linesRemoved: removed,
|
||||
snippetIndex: index,
|
||||
};
|
||||
});
|
||||
|
||||
const timestamps = events
|
||||
.map((event) => new Date(event.timestamp).getTime())
|
||||
.filter((timestamp) => !Number.isNaN(timestamp));
|
||||
const durationMs =
|
||||
timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0;
|
||||
|
||||
return { filePath, events, durationMs };
|
||||
}
|
||||
|
||||
private generateEditSummary(snippet: SnippetDiff): string {
|
||||
switch (snippet.type) {
|
||||
case 'write-new':
|
||||
return 'Created new file';
|
||||
case 'write-update':
|
||||
return 'Wrote full file content';
|
||||
case 'multi-edit': {
|
||||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||||
const total = added + removed;
|
||||
return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`;
|
||||
}
|
||||
case 'edit': {
|
||||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
||||
if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
|
||||
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
|
||||
return `Changed ${removed} → ${added} lines`;
|
||||
}
|
||||
default:
|
||||
return 'File modified';
|
||||
}
|
||||
}
|
||||
|
||||
private computeContextHash(oldString: string, newString: string): string {
|
||||
const take3 = (value: string): string => {
|
||||
const lines = value.split('\n');
|
||||
const head = lines.slice(0, 3).join('\n');
|
||||
const tail = lines.length > 3 ? lines.slice(-3).join('\n') : '';
|
||||
return `${head}|${tail}`;
|
||||
};
|
||||
|
||||
const raw = `${take3(oldString)}::${take3(newString)}`;
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < raw.length; index++) {
|
||||
hash = ((hash << 5) + hash + raw.charCodeAt(index)) | 0;
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] {
|
||||
return snippets
|
||||
.map((snippet, originalIndex) => ({ snippet, originalIndex }))
|
||||
.sort((a, b) => {
|
||||
const aMs = Date.parse(a.snippet.timestamp);
|
||||
const bMs = Date.parse(b.snippet.timestamp);
|
||||
const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER;
|
||||
const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER;
|
||||
if (safeA !== safeB) return safeA - safeB;
|
||||
if (a.snippet.filePath !== b.snippet.filePath) {
|
||||
return a.snippet.filePath.localeCompare(b.snippet.filePath);
|
||||
}
|
||||
if (a.snippet.toolUseId !== b.snippet.toolUseId) {
|
||||
return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId);
|
||||
}
|
||||
return a.originalIndex - b.originalIndex;
|
||||
})
|
||||
.map(({ snippet }) => snippet);
|
||||
}
|
||||
|
||||
private normalizeFilePathKey(filePath: string): string {
|
||||
return normalizeTaskChangePresenceFilePath(filePath);
|
||||
}
|
||||
}
|
||||
267
src/main/services/team/TaskChangeWorkerClient.ts
Normal file
267
src/main/services/team/TaskChangeWorkerClient.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type {
|
||||
ResolvedTaskChangeComputeInput,
|
||||
TaskChangeWorkerRequest,
|
||||
TaskChangeWorkerResponse,
|
||||
} from './taskChangeWorkerTypes';
|
||||
import type { TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TaskChangeWorkerClient');
|
||||
const DEFAULT_WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface WorkerLike {
|
||||
on(event: 'message', listener: (msg: TaskChangeWorkerResponse) => void): this;
|
||||
on(event: 'error', listener: (error: Error) => void): this;
|
||||
on(event: 'exit', listener: (code: number) => void): this;
|
||||
postMessage(message: TaskChangeWorkerRequest): void;
|
||||
terminate(): Promise<number>;
|
||||
}
|
||||
|
||||
interface QueueEntry {
|
||||
id: string;
|
||||
request: TaskChangeWorkerRequest;
|
||||
resolve: (value: TaskChangeSetV2) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
function makeId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
path.join(baseDir, 'task-change-worker.cjs'),
|
||||
path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.cjs'),
|
||||
path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.js'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class TaskChangeWorkerClient {
|
||||
private worker: WorkerLike | null = null;
|
||||
private terminatingWorker: WorkerLike | null = null;
|
||||
private readonly workerPath: string | null;
|
||||
private readonly workerFactory: (workerPath: string) => WorkerLike;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly enabled: boolean;
|
||||
private warnedUnavailable = false;
|
||||
private activeRequestId: string | null = null;
|
||||
private activeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private terminatingForTimeoutRequestId: string | null = null;
|
||||
private pending = new Map<string, QueueEntry>();
|
||||
private queue: QueueEntry[] = [];
|
||||
|
||||
constructor(options?: {
|
||||
workerPath?: string | null;
|
||||
workerFactory?: (workerPath: string) => WorkerLike;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
this.workerPath =
|
||||
options && 'workerPath' in options ? (options.workerPath ?? null) : resolveWorkerPath();
|
||||
this.workerFactory = options?.workerFactory ?? ((workerPath) => new Worker(workerPath));
|
||||
this.timeoutMs = options?.timeoutMs ?? DEFAULT_WORKER_CALL_TIMEOUT_MS;
|
||||
this.enabled = options?.enabled ?? process.env.CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER !== '0';
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
if (!this.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
this.warnedUnavailable = true;
|
||||
logger.warn('task-change-worker not found; falling back to main-thread extraction.');
|
||||
}
|
||||
|
||||
return this.workerPath !== null;
|
||||
}
|
||||
|
||||
async computeTaskChanges(payload: ResolvedTaskChangeComputeInput): Promise<TaskChangeSetV2> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Task change worker is not available in this environment');
|
||||
}
|
||||
|
||||
const id = makeId();
|
||||
const entry: QueueEntry = {
|
||||
id,
|
||||
request: { id, op: 'computeTaskChanges', payload },
|
||||
resolve: () => undefined,
|
||||
reject: () => undefined,
|
||||
};
|
||||
|
||||
return new Promise<TaskChangeSetV2>((resolve, reject) => {
|
||||
entry.resolve = resolve;
|
||||
entry.reject = reject;
|
||||
this.pending.set(id, entry);
|
||||
this.queue.push(entry);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private ensureWorker(): WorkerLike {
|
||||
if (!this.workerPath) {
|
||||
throw new Error('Task change worker is not available in this environment');
|
||||
}
|
||||
if (this.worker) {
|
||||
return this.worker;
|
||||
}
|
||||
|
||||
const worker = this.workerFactory(this.workerPath);
|
||||
worker.on('message', (msg) => this.handleMessage(msg));
|
||||
worker.on('error', (error) => this.handleWorkerFailure(worker, error));
|
||||
worker.on('exit', (code) => this.handleWorkerExit(worker, code));
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
if (this.activeRequestId || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.queue.shift();
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = this.ensureWorker();
|
||||
this.activeRequestId = entry.id;
|
||||
this.activeTimeout = setTimeout(() => {
|
||||
const activeId = this.activeRequestId;
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearActiveState();
|
||||
this.terminatingForTimeoutRequestId = activeId;
|
||||
const pending = this.pending.get(activeId);
|
||||
if (pending) {
|
||||
this.pending.delete(activeId);
|
||||
pending.reject(
|
||||
new Error(`Worker call timeout after ${this.timeoutMs}ms (computeTaskChanges)`)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const workerToTerminate = this.worker;
|
||||
this.terminatingWorker = workerToTerminate;
|
||||
workerToTerminate?.terminate().catch(() => undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
this.worker = null;
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
}, this.timeoutMs);
|
||||
|
||||
try {
|
||||
worker.postMessage(entry.request);
|
||||
} catch (error) {
|
||||
this.clearActiveState();
|
||||
this.pending.delete(entry.id);
|
||||
entry.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: TaskChangeWorkerResponse): void {
|
||||
const entry = this.pending.get(message.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.delete(message.id);
|
||||
if (this.activeRequestId === message.id) {
|
||||
this.clearActiveState();
|
||||
}
|
||||
|
||||
if (message.ok) {
|
||||
entry.resolve(message.result);
|
||||
} else {
|
||||
entry.reject(new Error(message.error));
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private handleWorkerFailure(worker: WorkerLike, error: Error): void {
|
||||
logger.error('Task change worker error', error);
|
||||
if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) {
|
||||
this.terminatingForTimeoutRequestId = null;
|
||||
this.terminatingWorker = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.rejectAllPending(error);
|
||||
this.clearActiveState();
|
||||
if (this.worker === worker) {
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerExit(worker: WorkerLike, code: number): void {
|
||||
if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) {
|
||||
this.terminatingForTimeoutRequestId = null;
|
||||
this.terminatingWorker = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.warn(`Task change worker exited with code ${code}`);
|
||||
}
|
||||
this.rejectAllPending(new Error(`Worker exited with code ${code}`));
|
||||
this.clearActiveState();
|
||||
if (this.worker === worker) {
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAllPending(error: Error): void {
|
||||
for (const entry of this.pending.values()) {
|
||||
entry.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
private clearActiveState(): void {
|
||||
this.activeRequestId = null;
|
||||
if (this.activeTimeout) {
|
||||
clearTimeout(this.activeTimeout);
|
||||
this.activeTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: TaskChangeWorkerClient | null = null;
|
||||
|
||||
export function getTaskChangeWorkerClient(): TaskChangeWorkerClient {
|
||||
if (!singleton) {
|
||||
singleton = new TaskChangeWorkerClient();
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
|||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
|
|
@ -63,11 +64,15 @@ import type {
|
|||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TaskChangePresenceState,
|
||||
TeamTaskWithKanban,
|
||||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { AgentTeamsController } from 'agent-teams-controller';
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
|
|
@ -91,6 +96,11 @@ interface EligibleTaskCommentNotification {
|
|||
summary: string;
|
||||
}
|
||||
|
||||
interface TaskChangeLogSourceSnapshot {
|
||||
projectFingerprint: string | null;
|
||||
logSourceGeneration: string | null;
|
||||
}
|
||||
|
||||
export class TeamDataService {
|
||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private processHealthTeams = new Set<string>();
|
||||
|
|
@ -98,6 +108,8 @@ export class TeamDataService {
|
|||
private notifiedTaskStarts = new Set<string>();
|
||||
private taskCommentNotificationInitialization: Promise<void> | null = null;
|
||||
private taskCommentNotificationInFlight = new Set<string>();
|
||||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -168,6 +180,120 @@ export class TeamDataService {
|
|||
return null;
|
||||
}
|
||||
|
||||
setTaskChangePresenceServices(
|
||||
repository: TaskChangePresenceRepository,
|
||||
tracker: TeamLogSourceTracker
|
||||
): void {
|
||||
this.taskChangePresenceRepository = repository;
|
||||
this.teamLogSourceTracker = tracker;
|
||||
}
|
||||
|
||||
setTaskChangePresenceTracking(teamName: string, enabled: boolean): void {
|
||||
if (!this.teamLogSourceTracker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
void this.teamLogSourceTracker
|
||||
.ensureTracking(teamName)
|
||||
.catch((error) =>
|
||||
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.teamLogSourceTracker
|
||||
.stopTracking(teamName)
|
||||
.catch((error) =>
|
||||
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
|
||||
);
|
||||
}
|
||||
|
||||
private resolveTaskChangePresenceMap(
|
||||
tasks: readonly TeamTaskWithKanban[],
|
||||
changePresenceEnabled: boolean,
|
||||
presenceIndex: PersistedTaskChangePresenceIndex | null,
|
||||
logSourceSnapshot: TaskChangeLogSourceSnapshot | null
|
||||
): Record<string, TaskChangePresenceState> {
|
||||
const result: Record<string, TaskChangePresenceState> = {};
|
||||
if (
|
||||
!changePresenceEnabled ||
|
||||
!presenceIndex ||
|
||||
!logSourceSnapshot?.projectFingerprint ||
|
||||
!logSourceSnapshot.logSourceGeneration ||
|
||||
presenceIndex.projectFingerprint !== logSourceSnapshot.projectFingerprint ||
|
||||
presenceIndex.logSourceGeneration !== logSourceSnapshot.logSourceGeneration
|
||||
) {
|
||||
for (const task of tasks) {
|
||||
result[task.id] = 'unknown';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const descriptor = buildTaskChangePresenceDescriptor({
|
||||
owner: task.owner,
|
||||
status: task.status,
|
||||
intervals: task.workIntervals,
|
||||
reviewState: task.reviewState,
|
||||
historyEvents: task.historyEvents,
|
||||
kanbanColumn: task.kanbanColumn,
|
||||
});
|
||||
const presenceEntry = presenceIndex.entries[task.id];
|
||||
result[task.id] =
|
||||
presenceEntry &&
|
||||
presenceEntry.taskSignature === descriptor.taskSignature &&
|
||||
presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration
|
||||
? presenceEntry.presence
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
throw new Error(`Team not found: ${teamName}`);
|
||||
}
|
||||
|
||||
const changePresenceEnabled =
|
||||
this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null;
|
||||
const logSourceSnapshot: TaskChangeLogSourceSnapshot | null =
|
||||
changePresenceEnabled &&
|
||||
typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown })
|
||||
.getSnapshot === 'function'
|
||||
? ((
|
||||
this.teamLogSourceTracker as {
|
||||
getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null;
|
||||
}
|
||||
).getSnapshot(teamName) ?? null)
|
||||
: null;
|
||||
|
||||
const [tasks, kanbanState, presenceIndex] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]),
|
||||
this.kanbanManager
|
||||
.getState(teamName)
|
||||
.catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState),
|
||||
changePresenceEnabled &&
|
||||
logSourceSnapshot?.projectFingerprint &&
|
||||
logSourceSnapshot.logSourceGeneration
|
||||
? this.taskChangePresenceRepository!.load(teamName)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
|
||||
);
|
||||
|
||||
return this.resolveTaskChangePresenceMap(
|
||||
tasksWithKanbanBase,
|
||||
changePresenceEnabled,
|
||||
presenceIndex,
|
||||
logSourceSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
return this.configReader.listTeams();
|
||||
}
|
||||
|
|
@ -333,6 +459,24 @@ export class TeamDataService {
|
|||
mark('config');
|
||||
|
||||
const warnings: string[] = [];
|
||||
const changePresenceEnabled =
|
||||
this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null;
|
||||
const logSourceSnapshot: TaskChangeLogSourceSnapshot | null =
|
||||
changePresenceEnabled &&
|
||||
typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown })
|
||||
.getSnapshot === 'function'
|
||||
? ((
|
||||
this.teamLogSourceTracker as {
|
||||
getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null;
|
||||
}
|
||||
).getSnapshot(teamName) ?? null)
|
||||
: null;
|
||||
const presenceIndexPromise =
|
||||
changePresenceEnabled &&
|
||||
logSourceSnapshot?.projectFingerprint &&
|
||||
logSourceSnapshot.logSourceGeneration
|
||||
? this.taskChangePresenceRepository!.load(teamName)
|
||||
: Promise.resolve(null);
|
||||
|
||||
let tasks: TeamTask[] = [];
|
||||
try {
|
||||
|
|
@ -473,10 +617,25 @@ export class TeamDataService {
|
|||
|
||||
mark('kanbanGc');
|
||||
|
||||
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
|
||||
);
|
||||
|
||||
const presenceIndex = await presenceIndexPromise;
|
||||
|
||||
const taskChangePresenceById = this.resolveTaskChangePresenceMap(
|
||||
tasksWithKanbanBase,
|
||||
changePresenceEnabled,
|
||||
presenceIndex,
|
||||
logSourceSnapshot
|
||||
);
|
||||
const tasksWithKanban: TeamTaskWithKanban[] = changePresenceEnabled
|
||||
? tasksWithKanbanBase.map((task) => ({
|
||||
...task,
|
||||
changePresence: taskChangePresenceById[task.id] ?? 'unknown',
|
||||
}))
|
||||
: tasksWithKanbanBase;
|
||||
|
||||
const members = this.memberResolver.resolveMembers(
|
||||
config,
|
||||
metaMembers,
|
||||
|
|
@ -492,10 +651,6 @@ export class TeamDataService {
|
|||
|
||||
mark('syncComments');
|
||||
|
||||
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
|
||||
);
|
||||
|
||||
let processes: TeamProcess[] = [];
|
||||
try {
|
||||
processes = await this.readProcesses(teamName);
|
||||
|
|
@ -530,7 +685,7 @@ export class TeamDataService {
|
|||
return {
|
||||
teamName,
|
||||
config,
|
||||
tasks: tasksToReturn,
|
||||
tasks: tasksWithKanban,
|
||||
members,
|
||||
messages,
|
||||
kanbanState,
|
||||
|
|
|
|||
361
src/main/services/team/TeamLogSourceTracker.ts
Normal file
361
src/main/services/team/TeamLogSourceTracker.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { watch } from 'chokidar';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
computeTaskChangePresenceProjectFingerprint,
|
||||
normalizeTaskChangePresenceFilePath,
|
||||
} from './taskChangePresenceUtils';
|
||||
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
|
||||
const logger = createLogger('Service:TeamLogSourceTracker');
|
||||
|
||||
interface TeamLogSourceSnapshot {
|
||||
projectFingerprint: string | null;
|
||||
logSourceGeneration: string | null;
|
||||
}
|
||||
|
||||
interface TrackingState {
|
||||
watcher: FSWatcher | null;
|
||||
projectDir: string | null;
|
||||
refreshTimer: ReturnType<typeof setTimeout> | null;
|
||||
initializePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||
initializeVersion: number | null;
|
||||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||
recomputeVersion: number | null;
|
||||
snapshot: TeamLogSourceSnapshot;
|
||||
desiredTracking: boolean;
|
||||
lifecycleVersion: number;
|
||||
}
|
||||
|
||||
export class TeamLogSourceTracker {
|
||||
private readonly stateByTeam = new Map<string, TrackingState>();
|
||||
private emitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||
|
||||
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
|
||||
|
||||
setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
return state ? { ...state.snapshot } : null;
|
||||
}
|
||||
|
||||
async ensureTracking(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.desiredTracking) {
|
||||
state.desiredTracking = true;
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
state.initializePromise &&
|
||||
state.initializeVersion === state.lifecycleVersion &&
|
||||
state.desiredTracking
|
||||
) {
|
||||
return state.initializePromise;
|
||||
}
|
||||
|
||||
const initializeVersion = state.lifecycleVersion;
|
||||
const initializePromise = this.initializeTeam(teamName, initializeVersion)
|
||||
.catch((error) => {
|
||||
logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`);
|
||||
return { projectFingerprint: null, logSourceGeneration: null };
|
||||
})
|
||||
.finally(() => {
|
||||
const current = this.stateByTeam.get(teamName);
|
||||
if (current?.initializePromise === initializePromise) {
|
||||
current.initializePromise = null;
|
||||
current.initializeVersion = null;
|
||||
}
|
||||
});
|
||||
|
||||
state.initializePromise = initializePromise;
|
||||
state.initializeVersion = initializeVersion;
|
||||
return initializePromise;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName)));
|
||||
}
|
||||
|
||||
private getOrCreateState(teamName: string): TrackingState {
|
||||
const existing = this.stateByTeam.get(teamName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: TrackingState = {
|
||||
watcher: null,
|
||||
projectDir: null,
|
||||
refreshTimer: null,
|
||||
initializePromise: null,
|
||||
initializeVersion: null,
|
||||
recomputePromise: null,
|
||||
recomputeVersion: null,
|
||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||
desiredTracking: false,
|
||||
lifecycleVersion: 0,
|
||||
};
|
||||
this.stateByTeam.set(teamName, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async stopTracking(teamName: string): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.desiredTracking) {
|
||||
state.desiredTracking = false;
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (state.refreshTimer) {
|
||||
clearTimeout(state.refreshTimer);
|
||||
state.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (state.watcher) {
|
||||
await state.watcher.close().catch(() => undefined);
|
||||
state.watcher = null;
|
||||
}
|
||||
|
||||
state.projectDir = null;
|
||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||
}
|
||||
|
||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion;
|
||||
}
|
||||
|
||||
private async initializeTeam(
|
||||
teamName: string,
|
||||
expectedVersion: number
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
const previousGeneration = state.snapshot.logSourceGeneration;
|
||||
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||
return this.getOrCreateState(teamName).snapshot;
|
||||
}
|
||||
if (!context) {
|
||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||
await this.rebuildWatcher(teamName, null, expectedVersion);
|
||||
return state.snapshot;
|
||||
}
|
||||
|
||||
const snapshot = await this.computeSnapshot(context);
|
||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||
return this.getOrCreateState(teamName).snapshot;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
await this.rebuildWatcher(teamName, context.projectDir, expectedVersion);
|
||||
if (
|
||||
this.isTrackingCurrent(teamName, expectedVersion) &&
|
||||
state.snapshot.logSourceGeneration &&
|
||||
previousGeneration !== state.snapshot.logSourceGeneration
|
||||
) {
|
||||
this.emitter?.({
|
||||
type: 'log-source-change',
|
||||
teamName,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async rebuildWatcher(
|
||||
teamName: string,
|
||||
projectDir: string | null,
|
||||
expectedVersion: number
|
||||
): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) {
|
||||
return;
|
||||
}
|
||||
if (state.projectDir === projectDir && state.watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.watcher) {
|
||||
await state.watcher.close().catch(() => undefined);
|
||||
state.watcher = null;
|
||||
}
|
||||
|
||||
state.projectDir = projectDir;
|
||||
if (!projectDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||
state.projectDir = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.watcher = watch(projectDir, {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: false,
|
||||
depth: 3,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 250,
|
||||
pollInterval: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleRecompute = (): void => {
|
||||
const current = this.stateByTeam.get(teamName);
|
||||
if (!current || !current.desiredTracking) {
|
||||
return;
|
||||
}
|
||||
if (current.refreshTimer) {
|
||||
clearTimeout(current.refreshTimer);
|
||||
}
|
||||
current.refreshTimer = setTimeout(() => {
|
||||
current.refreshTimer = null;
|
||||
void this.recompute(teamName);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
state.watcher.on('add', scheduleRecompute);
|
||||
state.watcher.on('change', scheduleRecompute);
|
||||
state.watcher.on('unlink', scheduleRecompute);
|
||||
state.watcher.on('addDir', scheduleRecompute);
|
||||
state.watcher.on('unlinkDir', scheduleRecompute);
|
||||
state.watcher.on('error', (error) => {
|
||||
logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.desiredTracking) {
|
||||
return state.snapshot;
|
||||
}
|
||||
if (
|
||||
state.recomputePromise &&
|
||||
state.recomputeVersion === state.lifecycleVersion &&
|
||||
state.desiredTracking
|
||||
) {
|
||||
return state.recomputePromise;
|
||||
}
|
||||
|
||||
const recomputeVersion = state.lifecycleVersion;
|
||||
const recomputePromise = (async () => {
|
||||
const previousGeneration = state.snapshot.logSourceGeneration;
|
||||
const context = await this.logsFinder.getLogSourceWatchContext(teamName, {
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
|
||||
return this.getOrCreateState(teamName).snapshot;
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||
await this.rebuildWatcher(teamName, null, recomputeVersion);
|
||||
} else {
|
||||
state.snapshot = await this.computeSnapshot(context);
|
||||
if (!this.isTrackingCurrent(teamName, recomputeVersion)) {
|
||||
return this.getOrCreateState(teamName).snapshot;
|
||||
}
|
||||
await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion);
|
||||
}
|
||||
|
||||
if (
|
||||
this.isTrackingCurrent(teamName, recomputeVersion) &&
|
||||
previousGeneration &&
|
||||
state.snapshot.logSourceGeneration &&
|
||||
previousGeneration !== state.snapshot.logSourceGeneration
|
||||
) {
|
||||
this.emitter?.({
|
||||
type: 'log-source-change',
|
||||
teamName,
|
||||
});
|
||||
}
|
||||
|
||||
return state.snapshot;
|
||||
})().finally(() => {
|
||||
const current = this.stateByTeam.get(teamName);
|
||||
if (current?.recomputePromise === recomputePromise) {
|
||||
current.recomputePromise = null;
|
||||
current.recomputeVersion = null;
|
||||
}
|
||||
});
|
||||
|
||||
state.recomputePromise = recomputePromise;
|
||||
state.recomputeVersion = recomputeVersion;
|
||||
return recomputePromise;
|
||||
}
|
||||
|
||||
private async computeSnapshot(context: {
|
||||
projectDir: string;
|
||||
projectPath?: string;
|
||||
leadSessionId?: string;
|
||||
sessionIds: string[];
|
||||
}): Promise<TeamLogSourceSnapshot> {
|
||||
const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (context.leadSessionId) {
|
||||
const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`);
|
||||
parts.push(await this.describePath('lead', leadLogPath));
|
||||
}
|
||||
|
||||
for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) {
|
||||
const sessionDir = path.join(context.projectDir, sessionId);
|
||||
const subagentsDir = path.join(sessionDir, 'subagents');
|
||||
parts.push(await this.describePath('session', sessionDir));
|
||||
parts.push(await this.describePath('subagents', subagentsDir));
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(subagentsDir);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
for (const fileName of entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.startsWith('agent-') &&
|
||||
entry.endsWith('.jsonl') &&
|
||||
!entry.startsWith('agent-acompact')
|
||||
)
|
||||
.sort((a, b) => a.localeCompare(b))) {
|
||||
parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName)));
|
||||
}
|
||||
}
|
||||
|
||||
const sourceMaterial =
|
||||
parts.length > 0
|
||||
? parts.join('|')
|
||||
: `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`;
|
||||
|
||||
return {
|
||||
projectFingerprint,
|
||||
logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
private async describePath(kind: string, targetPath: string): Promise<string> {
|
||||
const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath);
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
const type = stats.isDirectory() ? 'dir' : 'file';
|
||||
return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`;
|
||||
} catch {
|
||||
return `${kind}:missing:${normalizedPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,6 +173,32 @@ export class TeamMemberLogsFinder {
|
|||
);
|
||||
}
|
||||
|
||||
async getLogSourceWatchContext(
|
||||
teamName: string,
|
||||
options?: { forceRefresh?: boolean }
|
||||
): Promise<{
|
||||
projectDir: string;
|
||||
projectPath?: string;
|
||||
leadSessionId?: string;
|
||||
sessionIds: string[];
|
||||
} | null> {
|
||||
if (options?.forceRefresh) {
|
||||
this.discoveryCache.delete(teamName);
|
||||
}
|
||||
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
projectDir: discovery.projectDir,
|
||||
projectPath: discovery.config.projectPath,
|
||||
leadSessionId: discovery.config.leadSessionId,
|
||||
sessionIds: [...discovery.sessionIds],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
|
||||
* When the task is in_progress and has an owner, also includes that owner's session logs so
|
||||
|
|
|
|||
140
src/main/services/team/cache/JsonTaskChangePresenceRepository.ts
vendored
Normal file
140
src/main/services/team/cache/JsonTaskChangePresenceRepository.ts
vendored
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
normalizePersistedTaskChangePresenceIndex,
|
||||
toPersistedTaskChangePresenceIndex,
|
||||
} from './taskChangePresenceCacheSchema';
|
||||
import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes';
|
||||
|
||||
import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository';
|
||||
import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes';
|
||||
|
||||
const logger = createLogger('Service:JsonTaskChangePresenceRepository');
|
||||
|
||||
const READ_TIMEOUT_MS = 5_000;
|
||||
|
||||
function encodeFileSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository {
|
||||
private readonly writeChains = new Map<string, Promise<void>>();
|
||||
|
||||
private get basePath(): string {
|
||||
return getTaskChangePresenceBasePath();
|
||||
}
|
||||
|
||||
private filePath(teamName: string): string {
|
||||
return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`);
|
||||
}
|
||||
|
||||
private async readIndex(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
|
||||
const filePath = this.filePath(teamName);
|
||||
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 task-change presence index ${filePath}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content) as unknown;
|
||||
} catch (error) {
|
||||
logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`);
|
||||
await fs.promises.unlink(filePath).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizePersistedTaskChangePresenceIndex(parsed);
|
||||
if (!normalized) {
|
||||
await fs.promises.unlink(filePath).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async load(teamName: string): Promise<PersistedTaskChangePresenceIndex | null> {
|
||||
return this.readIndex(teamName);
|
||||
}
|
||||
|
||||
async upsertEntry(
|
||||
teamName: string,
|
||||
metadata: {
|
||||
projectFingerprint: string;
|
||||
logSourceGeneration: string;
|
||||
writtenAt: string;
|
||||
},
|
||||
entry: {
|
||||
taskId: string;
|
||||
taskSignature: string;
|
||||
presence: 'has_changes' | 'no_changes';
|
||||
writtenAt: string;
|
||||
logSourceGeneration: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const write = async (): Promise<void> => {
|
||||
const current =
|
||||
(await this.readIndex(teamName)) ??
|
||||
({
|
||||
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
|
||||
teamName,
|
||||
projectFingerprint: metadata.projectFingerprint,
|
||||
logSourceGeneration: metadata.logSourceGeneration,
|
||||
writtenAt: metadata.writtenAt,
|
||||
entries: {},
|
||||
} satisfies PersistedTaskChangePresenceIndex);
|
||||
|
||||
const next = toPersistedTaskChangePresenceIndex({
|
||||
...current,
|
||||
projectFingerprint: metadata.projectFingerprint,
|
||||
logSourceGeneration: metadata.logSourceGeneration,
|
||||
writtenAt: metadata.writtenAt,
|
||||
entries: {
|
||||
...current.entries,
|
||||
[entry.taskId]: {
|
||||
taskId: entry.taskId,
|
||||
taskSignature: entry.taskSignature,
|
||||
presence: entry.presence,
|
||||
writtenAt: entry.writtenAt,
|
||||
logSourceGeneration: entry.logSourceGeneration,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
|
||||
};
|
||||
|
||||
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(write)
|
||||
.finally(() => {
|
||||
if (this.writeChains.get(teamName) === next) {
|
||||
this.writeChains.delete(teamName);
|
||||
}
|
||||
});
|
||||
|
||||
this.writeChains.set(teamName, next);
|
||||
await next;
|
||||
}
|
||||
}
|
||||
23
src/main/services/team/cache/TaskChangePresenceRepository.ts
vendored
Normal file
23
src/main/services/team/cache/TaskChangePresenceRepository.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type {
|
||||
PersistedTaskChangePresence,
|
||||
PersistedTaskChangePresenceIndex,
|
||||
} from './taskChangePresenceCacheTypes';
|
||||
|
||||
export interface TaskChangePresenceRepository {
|
||||
load(teamName: string): Promise<PersistedTaskChangePresenceIndex | null>;
|
||||
upsertEntry(
|
||||
teamName: string,
|
||||
metadata: {
|
||||
projectFingerprint: string;
|
||||
logSourceGeneration: string;
|
||||
writtenAt: string;
|
||||
},
|
||||
entry: {
|
||||
taskId: string;
|
||||
taskSignature: string;
|
||||
presence: PersistedTaskChangePresence;
|
||||
writtenAt: string;
|
||||
logSourceGeneration: string;
|
||||
}
|
||||
): Promise<void>;
|
||||
}
|
||||
107
src/main/services/team/cache/taskChangePresenceCacheSchema.ts
vendored
Normal file
107
src/main/services/team/cache/taskChangePresenceCacheSchema.ts
vendored
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
|
||||
type PersistedTaskChangePresence,
|
||||
type PersistedTaskChangePresenceEntry,
|
||||
type PersistedTaskChangePresenceIndex,
|
||||
} from './taskChangePresenceCacheTypes';
|
||||
|
||||
function isIsoString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
function normalizePresence(value: unknown): PersistedTaskChangePresence | null {
|
||||
return value === 'has_changes' || value === 'no_changes' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const normalizedPresence = normalizePresence(raw.presence);
|
||||
if (
|
||||
typeof raw.taskSignature !== 'string' ||
|
||||
!normalizedPresence ||
|
||||
!isIsoString(raw.writtenAt) ||
|
||||
typeof raw.logSourceGeneration !== 'string' ||
|
||||
raw.logSourceGeneration.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
taskSignature: raw.taskSignature,
|
||||
presence: normalizedPresence,
|
||||
writtenAt: raw.writtenAt,
|
||||
logSourceGeneration: raw.logSourceGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePersistedTaskChangePresenceIndex(
|
||||
value: unknown
|
||||
): PersistedTaskChangePresenceIndex | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (
|
||||
raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION ||
|
||||
typeof raw.teamName !== 'string' ||
|
||||
typeof raw.projectFingerprint !== 'string' ||
|
||||
raw.projectFingerprint.length === 0 ||
|
||||
typeof raw.logSourceGeneration !== 'string' ||
|
||||
raw.logSourceGeneration.length === 0 ||
|
||||
!isIsoString(raw.writtenAt) ||
|
||||
!raw.entries ||
|
||||
typeof raw.entries !== 'object'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEntries: Record<string, PersistedTaskChangePresenceEntry> = {};
|
||||
for (const [taskId, entryValue] of Object.entries(raw.entries as Record<string, unknown>)) {
|
||||
if (typeof taskId !== 'string' || taskId.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeEntry(taskId, entryValue);
|
||||
if (normalized) {
|
||||
normalizedEntries[taskId] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
|
||||
teamName: raw.teamName,
|
||||
projectFingerprint: raw.projectFingerprint,
|
||||
logSourceGeneration: raw.logSourceGeneration,
|
||||
writtenAt: raw.writtenAt,
|
||||
entries: normalizedEntries,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedTaskChangePresenceIndex(
|
||||
value: PersistedTaskChangePresenceIndex
|
||||
): PersistedTaskChangePresenceIndex {
|
||||
return {
|
||||
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
|
||||
teamName: value.teamName,
|
||||
projectFingerprint: value.projectFingerprint,
|
||||
logSourceGeneration: value.logSourceGeneration,
|
||||
writtenAt: value.writtenAt,
|
||||
entries: Object.fromEntries(
|
||||
Object.entries(value.entries).map(([taskId, entry]) => [
|
||||
taskId,
|
||||
{
|
||||
taskId,
|
||||
taskSignature: entry.taskSignature,
|
||||
presence: entry.presence,
|
||||
writtenAt: entry.writtenAt,
|
||||
logSourceGeneration: entry.logSourceGeneration,
|
||||
},
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
22
src/main/services/team/cache/taskChangePresenceCacheTypes.ts
vendored
Normal file
22
src/main/services/team/cache/taskChangePresenceCacheTypes.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { TaskChangePresenceState } from '@shared/types/team';
|
||||
|
||||
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
|
||||
|
||||
export type PersistedTaskChangePresence = Exclude<TaskChangePresenceState, 'unknown'>;
|
||||
|
||||
export interface PersistedTaskChangePresenceEntry {
|
||||
taskId: string;
|
||||
taskSignature: string;
|
||||
presence: PersistedTaskChangePresence;
|
||||
writtenAt: string;
|
||||
logSourceGeneration: string;
|
||||
}
|
||||
|
||||
export interface PersistedTaskChangePresenceIndex {
|
||||
version: typeof TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION;
|
||||
teamName: string;
|
||||
projectFingerprint: string;
|
||||
logSourceGeneration: string;
|
||||
writtenAt: string;
|
||||
entries: Record<string, PersistedTaskChangePresenceEntry>;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export { TeamInboxReader } from './TeamInboxReader';
|
|||
export { TeamInboxWriter } from './TeamInboxWriter';
|
||||
export { TeamKanbanManager } from './TeamKanbanManager';
|
||||
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
export { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||
export { TeamMemberResolver } from './TeamMemberResolver';
|
||||
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
export { TeamProvisioningService } from './TeamProvisioningService';
|
||||
|
|
|
|||
152
src/main/services/team/taskChangePresenceUtils.ts
Normal file
152
src/main/services/team/taskChangePresenceUtils.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import {
|
||||
getTaskChangeStateBucket,
|
||||
type TaskChangeStateBucket,
|
||||
} from '@shared/utils/taskChangeState';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export interface TaskChangePresenceInterval {
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskChangePresenceDescriptorInput {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: TaskChangePresenceInterval[];
|
||||
since?: string;
|
||||
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
|
||||
historyEvents?: unknown[];
|
||||
kanbanColumn?: 'review' | 'approved';
|
||||
}
|
||||
|
||||
export interface TaskChangePresenceDescriptor {
|
||||
stateBucket: TaskChangeStateBucket;
|
||||
taskSignature: string;
|
||||
effectiveOptions: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: TaskChangePresenceInterval[];
|
||||
since?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function deriveIntervalsFromHistory(
|
||||
historyEvents?: unknown[]
|
||||
): TaskChangePresenceInterval[] | undefined {
|
||||
if (!Array.isArray(historyEvents) || historyEvents.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const transitions = historyEvents
|
||||
.map((event) =>
|
||||
event && typeof event === 'object' ? (event as Record<string, unknown>) : null
|
||||
)
|
||||
.filter((event): event is Record<string, unknown> => event !== null)
|
||||
.filter((event) => event.type === 'status_changed')
|
||||
.map((event) => ({
|
||||
to: typeof event.to === 'string' ? event.to : null,
|
||||
timestamp: typeof event.timestamp === 'string' ? event.timestamp : null,
|
||||
}))
|
||||
.filter(
|
||||
(transition): transition is { to: string; timestamp: string } =>
|
||||
transition.to !== null && transition.timestamp !== null
|
||||
)
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
if (transitions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const derived: TaskChangePresenceInterval[] = [];
|
||||
let currentStart: string | null = null;
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.to === 'in_progress') {
|
||||
if (!currentStart) {
|
||||
currentStart = transition.timestamp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStart) {
|
||||
derived.push({ startedAt: currentStart, completedAt: transition.timestamp });
|
||||
currentStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStart) {
|
||||
derived.push({ startedAt: currentStart });
|
||||
}
|
||||
|
||||
return derived.length > 0 ? derived : undefined;
|
||||
}
|
||||
|
||||
export function normalizeTaskChangePresenceFilePath(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase());
|
||||
}
|
||||
|
||||
export function computeTaskChangePresenceProjectFingerprint(
|
||||
projectPath?: string | null
|
||||
): string | null {
|
||||
const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : '';
|
||||
if (!normalizedProjectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(normalizeTaskChangePresenceFilePath(normalizedProjectPath))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function buildTaskChangePresenceDescriptor(
|
||||
input: TaskChangePresenceDescriptorInput
|
||||
): TaskChangePresenceDescriptor {
|
||||
const effectiveIntervals =
|
||||
Array.isArray(input.intervals) && input.intervals.length > 0
|
||||
? input.intervals.map((interval) => ({
|
||||
startedAt: interval.startedAt,
|
||||
completedAt: interval.completedAt ?? '',
|
||||
}))
|
||||
: (deriveIntervalsFromHistory(input.historyEvents)?.map((interval) => ({
|
||||
startedAt: interval.startedAt,
|
||||
completedAt: interval.completedAt ?? '',
|
||||
})) ?? []);
|
||||
|
||||
const stateBucket = getTaskChangeStateBucket({
|
||||
status: input.status,
|
||||
reviewState: input.reviewState,
|
||||
historyEvents: input.historyEvents,
|
||||
kanbanColumn: input.kanbanColumn,
|
||||
});
|
||||
|
||||
const effectiveOptions = {
|
||||
owner: typeof input.owner === 'string' ? input.owner.trim() : '',
|
||||
status: typeof input.status === 'string' ? input.status.trim() : '',
|
||||
intervals: effectiveIntervals,
|
||||
since: typeof input.since === 'string' ? input.since : '',
|
||||
};
|
||||
|
||||
return {
|
||||
stateBucket,
|
||||
taskSignature: JSON.stringify({
|
||||
owner: effectiveOptions.owner,
|
||||
status: effectiveOptions.status,
|
||||
since: effectiveOptions.since,
|
||||
stateBucket,
|
||||
intervals: effectiveIntervals,
|
||||
}),
|
||||
effectiveOptions: {
|
||||
owner: effectiveOptions.owner || undefined,
|
||||
status: effectiveOptions.status || undefined,
|
||||
intervals:
|
||||
effectiveIntervals.length > 0
|
||||
? effectiveIntervals.map((interval) => ({
|
||||
startedAt: interval.startedAt,
|
||||
completedAt: interval.completedAt || undefined,
|
||||
}))
|
||||
: undefined,
|
||||
since: effectiveOptions.since || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
49
src/main/services/team/taskChangeWorkerTypes.ts
Normal file
49
src/main/services/team/taskChangeWorkerTypes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
export interface TaskChangeTaskMeta {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
reviewState?: 'review' | 'needsFix' | 'approved' | 'none';
|
||||
historyEvents?: unknown[];
|
||||
kanbanColumn?: 'review' | 'approved';
|
||||
}
|
||||
|
||||
export interface TaskChangeEffectiveOptions {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTaskChangeComputeInput {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
taskMeta: TaskChangeTaskMeta | null;
|
||||
effectiveOptions: TaskChangeEffectiveOptions;
|
||||
projectPath?: string;
|
||||
includeDetails: boolean;
|
||||
}
|
||||
|
||||
export interface ComputeTaskChangesRequest {
|
||||
id: string;
|
||||
op: 'computeTaskChanges';
|
||||
payload: ResolvedTaskChangeComputeInput;
|
||||
}
|
||||
|
||||
export interface ComputeTaskChangesSuccessResponse {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: TaskChangeSetV2;
|
||||
}
|
||||
|
||||
export interface ComputeTaskChangesErrorResponse {
|
||||
id: string;
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type TaskChangeWorkerRequest = ComputeTaskChangesRequest;
|
||||
export type TaskChangeWorkerResponse =
|
||||
| ComputeTaskChangesSuccessResponse
|
||||
| ComputeTaskChangesErrorResponse;
|
||||
|
|
@ -393,6 +393,10 @@ export function getTaskChangeSummariesBasePath(): string {
|
|||
return path.join(getClaudeBasePath(), 'task-change-summaries');
|
||||
}
|
||||
|
||||
export function getTaskChangePresenceBasePath(): string {
|
||||
return path.join(getClaudeBasePath(), 'task-change-presence');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backups directory path for the app's own storage.
|
||||
*/
|
||||
|
|
|
|||
40
src/main/workers/task-change-worker.ts
Normal file
40
src/main/workers/task-change-worker.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser';
|
||||
import { TaskChangeComputer } from '@main/services/team/TaskChangeComputer';
|
||||
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
|
||||
import type {
|
||||
TaskChangeWorkerRequest,
|
||||
TaskChangeWorkerResponse,
|
||||
} from '@main/services/team/taskChangeWorkerTypes';
|
||||
|
||||
const logsFinder = new TeamMemberLogsFinder();
|
||||
const boundaryParser = new TaskBoundaryParser();
|
||||
const computer = new TaskChangeComputer(logsFinder, boundaryParser);
|
||||
|
||||
function postMessage(message: TaskChangeWorkerResponse): void {
|
||||
parentPort?.postMessage(message);
|
||||
}
|
||||
|
||||
parentPort?.on('message', async (message: TaskChangeWorkerRequest) => {
|
||||
if (!message || message.op !== 'computeTaskChanges') {
|
||||
postMessage({
|
||||
id: message?.id ?? 'unknown',
|
||||
ok: false,
|
||||
error: `Unsupported task change worker op: ${String(message?.op)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await computer.computeTaskChanges(message.payload);
|
||||
postMessage({ id: message.id, ok: true, result });
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -210,6 +210,12 @@ export const TEAM_LIST = 'team:list';
|
|||
/** Get detailed team data */
|
||||
export const TEAM_GET_DATA = 'team:getData';
|
||||
|
||||
/** Get lightweight task change presence map for the currently viewed team */
|
||||
export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence';
|
||||
|
||||
/** Enable or disable task change presence tracking for a visible team tab */
|
||||
export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking';
|
||||
|
||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import {
|
|||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_TASK_CHANGE_PRESENCE,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
|
|
@ -148,6 +149,7 @@ import {
|
|||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -260,6 +262,7 @@ import type {
|
|||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangePresenceState,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamChangeEvent,
|
||||
|
|
@ -800,6 +803,15 @@ const electronAPI: ElectronAPI = {
|
|||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getTaskChangePresence: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||
TEAM_GET_TASK_CHANGE_PRESENCE,
|
||||
teamName
|
||||
);
|
||||
},
|
||||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
||||
},
|
||||
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
||||
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -668,6 +668,14 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getTaskChangePresence: async (): Promise<
|
||||
Record<string, 'has_changes' | 'no_changes' | 'unknown'>
|
||||
> => {
|
||||
return {};
|
||||
},
|
||||
setChangePresenceTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
getClaudeLogs: async (
|
||||
_teamName: string,
|
||||
_query?: TeamClaudeLogsQuery
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
|
@ -22,11 +23,16 @@ export const TeamTabSectionNav = ({
|
|||
teamName,
|
||||
onActivate,
|
||||
}: TeamTabSectionNavProps): React.JSX.Element => {
|
||||
const messagesPanelMode = useStore((s) => s.messagesPanelMode);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
const visibleSections = SECTIONS.filter(
|
||||
(section) =>
|
||||
messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs')
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(sectionId: string) => {
|
||||
|
|
@ -99,7 +105,7 @@ export const TeamTabSectionNav = ({
|
|||
if (e.key === 'Escape') setOpen(false);
|
||||
}}
|
||||
>
|
||||
{SECTIONS.map((section) => {
|
||||
{visibleSections.map((section) => {
|
||||
const SectionIcon = section.icon;
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -90,10 +90,21 @@ import type {
|
|||
FileChangeSummary,
|
||||
KanbanTaskState,
|
||||
ResolvedTeamMember,
|
||||
TaskChangeSetV2,
|
||||
TaskAttachmentMeta,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
function resolveTaskChangePresenceFromResult(
|
||||
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
|
||||
): 'has_changes' | 'no_changes' | null {
|
||||
if (data.files.length > 0) {
|
||||
return 'has_changes';
|
||||
}
|
||||
|
||||
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
|
||||
}
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -135,6 +146,7 @@ export const TaskDetailDialog = ({
|
|||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
|
||||
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
|
||||
|
||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
||||
|
|
@ -324,7 +336,7 @@ export const TaskDetailDialog = ({
|
|||
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
||||
|
||||
const loadTaskChangeSummary = useCallback(
|
||||
async (forceFresh = false): Promise<FileChangeSummary[] | null> => {
|
||||
async (forceFresh = false): Promise<TaskChangeSetV2 | null> => {
|
||||
if (
|
||||
!currentTask ||
|
||||
!taskChangeSummaryOptions ||
|
||||
|
|
@ -338,7 +350,7 @@ export const TaskDetailDialog = ({
|
|||
...taskChangeSummaryOptions,
|
||||
forceFresh,
|
||||
});
|
||||
return data.files;
|
||||
return data;
|
||||
},
|
||||
[canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant]
|
||||
);
|
||||
|
|
@ -356,17 +368,21 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
setTaskChangesError(null);
|
||||
void loadTaskChangeSummary()
|
||||
.then((files) => {
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setTaskChangesFiles(files ?? null);
|
||||
setTaskChangesFiles(data?.files ?? null);
|
||||
if (currentTask && taskChangeRequestOptions) {
|
||||
recordTaskHasChanges(
|
||||
teamName,
|
||||
currentTask.id,
|
||||
taskChangeRequestOptions,
|
||||
!!files?.length
|
||||
!!data?.files.length
|
||||
);
|
||||
}
|
||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||
if (currentTask && nextPresence) {
|
||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -382,17 +398,21 @@ export const TaskDetailDialog = ({
|
|||
});
|
||||
|
||||
void loadTaskChangeSummary(true)
|
||||
.then((files) => {
|
||||
if (!cancelled && files) {
|
||||
setTaskChangesFiles(files);
|
||||
.then((data) => {
|
||||
if (!cancelled && data) {
|
||||
setTaskChangesFiles(data.files);
|
||||
if (currentTask && taskChangeRequestOptions) {
|
||||
recordTaskHasChanges(
|
||||
teamName,
|
||||
currentTask.id,
|
||||
taskChangeRequestOptions,
|
||||
files.length > 0
|
||||
data.files.length > 0
|
||||
);
|
||||
}
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
||||
if (currentTask && nextPresence) {
|
||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
@ -417,10 +437,19 @@ export const TaskDetailDialog = ({
|
|||
setTaskChangesLoading(true);
|
||||
setTaskChangesError(null);
|
||||
void loadTaskChangeSummary(true)
|
||||
.then((files) => {
|
||||
setTaskChangesFiles(files ?? null);
|
||||
.then((data) => {
|
||||
setTaskChangesFiles(data?.files ?? null);
|
||||
if (currentTask && taskChangeRequestOptions) {
|
||||
recordTaskHasChanges(teamName, currentTask.id, taskChangeRequestOptions, !!files?.length);
|
||||
recordTaskHasChanges(
|
||||
teamName,
|
||||
currentTask.id,
|
||||
taskChangeRequestOptions,
|
||||
!!data?.files.length
|
||||
);
|
||||
}
|
||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||
if (currentTask && nextPresence) {
|
||||
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -436,6 +465,7 @@ export const TaskDetailDialog = ({
|
|||
onViewChanges,
|
||||
loadTaskChangeSummary,
|
||||
recordTaskHasChanges,
|
||||
setSelectedTeamTaskChangePresence,
|
||||
taskChangeRequestOptions,
|
||||
teamName,
|
||||
variant,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
|
@ -8,6 +8,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
|
|
@ -201,7 +202,7 @@ interface SortableKanbanTaskCardProps {
|
|||
kanbanState: KanbanState;
|
||||
compact?: boolean;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
members: ResolvedTeamMember[];
|
||||
memberColorMap: Map<string, string>;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
|
|
@ -222,7 +223,7 @@ const SortableKanbanTaskCard = ({
|
|||
kanbanState,
|
||||
compact,
|
||||
taskMap,
|
||||
members,
|
||||
memberColorMap,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
|
|
@ -257,7 +258,7 @@ const SortableKanbanTaskCard = ({
|
|||
hasReviewers={kanbanState.reviewers.length > 0}
|
||||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
memberColorMap={memberColorMap}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -306,7 +307,28 @@ export const KanbanBoard = ({
|
|||
const enableTaskSorting =
|
||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
||||
const stableTaskMapRef = useRef<{
|
||||
signatures: string[];
|
||||
map: Map<string, TeamTask>;
|
||||
} | null>(null);
|
||||
const taskMap = useMemo(() => {
|
||||
const signatures = tasks.map(
|
||||
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
|
||||
);
|
||||
const previous = stableTaskMapRef.current;
|
||||
if (
|
||||
previous &&
|
||||
previous.signatures.length === signatures.length &&
|
||||
previous.signatures.every((signature, index) => signature === signatures[index])
|
||||
) {
|
||||
return previous.map;
|
||||
}
|
||||
|
||||
const next = new Map(tasks.map((task) => [task.id, task]));
|
||||
stableTaskMapRef.current = { signatures, map: next };
|
||||
return next;
|
||||
}, [tasks]);
|
||||
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const grouped = useMemo(() => {
|
||||
const result = new Map<KanbanColumnId, TeamTask[]>(
|
||||
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
|
||||
|
|
@ -406,7 +428,7 @@ export const KanbanBoard = ({
|
|||
kanbanState={kanbanState}
|
||||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
memberColorMap={memberColorMap}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -437,7 +459,7 @@ export const KanbanBoard = ({
|
|||
hasReviewers={kanbanState.reviewers.length > 0}
|
||||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
memberColorMap={memberColorMap}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
|
|
@ -6,10 +6,8 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
|
|
@ -28,17 +26,17 @@ import {
|
|||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface KanbanTaskCardProps {
|
||||
task: TeamTask;
|
||||
task: TeamTaskWithKanban;
|
||||
teamName: string;
|
||||
columnId: KanbanColumnId;
|
||||
kanbanTaskState?: KanbanTaskState;
|
||||
hasReviewers: boolean;
|
||||
compact?: boolean;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
members: ResolvedTeamMember[];
|
||||
memberColorMap: Map<string, string>;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
|
|
@ -190,6 +188,7 @@ interface TaskActionIconButtonProps {
|
|||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className: string;
|
||||
variant?: 'outline' | 'ghost' | 'destructive';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TaskActionIconButton = ({
|
||||
|
|
@ -198,6 +197,7 @@ const TaskActionIconButton = ({
|
|||
onClick,
|
||||
className,
|
||||
variant = 'outline',
|
||||
disabled = false,
|
||||
}: TaskActionIconButtonProps): React.JSX.Element => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -207,6 +207,7 @@ const TaskActionIconButton = ({
|
|||
className={`size-6 shrink-0 rounded-full shadow-sm ${className}`}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
|
|
@ -215,246 +216,201 @@ const TaskActionIconButton = ({
|
|||
</Tooltip>
|
||||
);
|
||||
|
||||
export const KanbanTaskCard = ({
|
||||
task,
|
||||
teamName,
|
||||
columnId,
|
||||
kanbanTaskState,
|
||||
hasReviewers,
|
||||
compact,
|
||||
taskMap,
|
||||
members,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onCancelTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
onViewChanges,
|
||||
onDeleteTask,
|
||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const hasBlockedBy = blockedByIds.length > 0;
|
||||
const hasBlocks = blocksIds.length > 0;
|
||||
|
||||
// Lazy-check if task has file changes
|
||||
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
||||
const canDisplay = useMemo(
|
||||
() => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
|
||||
[taskChangeRequestOptions, onViewChanges]
|
||||
);
|
||||
const cacheKey = useMemo(
|
||||
() => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions),
|
||||
[teamName, task.id, taskChangeRequestOptions]
|
||||
);
|
||||
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
|
||||
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
|
||||
|
||||
useEffect(() => {
|
||||
if (canDisplay && taskHasChanges === undefined) {
|
||||
void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions);
|
||||
}
|
||||
}, [
|
||||
canDisplay,
|
||||
task.id,
|
||||
export const KanbanTaskCard = memo(
|
||||
function KanbanTaskCard({
|
||||
task,
|
||||
teamName,
|
||||
taskHasChanges,
|
||||
checkTaskHasChanges,
|
||||
taskChangeRequestOptions,
|
||||
]);
|
||||
columnId,
|
||||
kanbanTaskState,
|
||||
hasReviewers,
|
||||
compact,
|
||||
taskMap,
|
||||
memberColorMap,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onCancelTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
onViewChanges,
|
||||
onDeleteTask,
|
||||
}: KanbanTaskCardProps): React.JSX.Element {
|
||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const hasBlockedBy = blockedByIds.length > 0;
|
||||
const hasBlocks = blocksIds.length > 0;
|
||||
|
||||
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
|
||||
const metaActions = (
|
||||
<>
|
||||
{canDisplay && taskHasChanges === true ? (
|
||||
<TaskActionIconButton
|
||||
label="Changes"
|
||||
icon={<FileCode className="size-2.5" />}
|
||||
variant="ghost"
|
||||
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewChanges!(task.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
{onDeleteTask ? (
|
||||
<TaskActionIconButton
|
||||
label="Delete task"
|
||||
icon={<Trash2 size={11} />}
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
||||
const canDisplay = useMemo(
|
||||
() => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
|
||||
[taskChangeRequestOptions, onViewChanges]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTaskClick?.(task);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</span>
|
||||
{task.owner ? (
|
||||
<span className="absolute right-[6px] top-[2px]">
|
||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} size="xs" />
|
||||
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
|
||||
const metaActions = (
|
||||
<>
|
||||
{canDisplay && task.changePresence === 'has_changes' ? (
|
||||
<TaskActionIconButton
|
||||
label="Changes"
|
||||
icon={<FileCode className="size-2.5" />}
|
||||
variant="ghost"
|
||||
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewChanges!(task.id);
|
||||
}}
|
||||
/>
|
||||
) : canDisplay && task.changePresence === 'no_changes' ? (
|
||||
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
No changes
|
||||
</span>
|
||||
) : null}
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
{onDeleteTask ? (
|
||||
<TaskActionIconButton
|
||||
label="Delete task"
|
||||
icon={<Trash2 size={11} />}
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTaskClick?.(task);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mb-2 pt-[11px]">
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
{task.needsClarification ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
task.needsClarification === 'user'
|
||||
? 'bg-red-500/15 text-red-400'
|
||||
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle size={10} />
|
||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||
{task.owner ? (
|
||||
<span className="absolute right-[6px] top-[2px]">
|
||||
<MemberBadge name={task.owner} color={memberColorMap.get(task.owner)} size="xs" />
|
||||
</span>
|
||||
) : null}
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
{REVIEW_STATE_DISPLAY.needsFix.label}
|
||||
</span>
|
||||
<div className="mb-2 pt-[11px]">
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
{task.needsClarification ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
task.needsClarification === 'user'
|
||||
? 'bg-red-500/15 text-red-400'
|
||||
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle size={10} />
|
||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||
</span>
|
||||
) : null}
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
{REVIEW_STATE_DISPLAY.needsFix.label}
|
||||
</span>
|
||||
) : null}
|
||||
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
|
||||
</div>
|
||||
|
||||
{hasBlockedBy ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
|
||||
<ArrowLeftFromLine size={10} />
|
||||
Blocked by
|
||||
</span>
|
||||
{blockedByIds.map((id) => (
|
||||
<DependencyBadge
|
||||
key={id}
|
||||
taskId={id}
|
||||
taskMap={taskMap}
|
||||
onScrollToTask={onScrollToTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
|
||||
</div>
|
||||
|
||||
{hasBlockedBy ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-700 dark:text-yellow-300">
|
||||
<ArrowLeftFromLine size={10} />
|
||||
Blocked by
|
||||
</span>
|
||||
{blockedByIds.map((id) => (
|
||||
<DependencyBadge
|
||||
key={id}
|
||||
taskId={id}
|
||||
taskMap={taskMap}
|
||||
onScrollToTask={onScrollToTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasBlocks ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400">
|
||||
<ArrowRightFromLine size={10} />
|
||||
Blocks
|
||||
</span>
|
||||
{blocksIds.map((id) => (
|
||||
<DependencyBadge
|
||||
key={id}
|
||||
taskId={id}
|
||||
taskMap={taskMap}
|
||||
onScrollToTask={onScrollToTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-nowrap gap-2">
|
||||
{columnId === 'todo' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Start"
|
||||
icon={<Play size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTask(task.id);
|
||||
}}
|
||||
{hasBlocks ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-blue-600 dark:text-blue-400">
|
||||
<ArrowRightFromLine size={10} />
|
||||
Blocks
|
||||
</span>
|
||||
{blocksIds.map((id) => (
|
||||
<DependencyBadge
|
||||
key={id}
|
||||
taskId={id}
|
||||
taskMap={taskMap}
|
||||
onScrollToTask={onScrollToTask}
|
||||
/>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'in_progress' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-nowrap gap-2">
|
||||
{columnId === 'todo' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Start"
|
||||
icon={<Play size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTask(task.id);
|
||||
}}
|
||||
/>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{columnId === 'done' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Approve"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprove(task.id);
|
||||
}}
|
||||
/>
|
||||
<TaskActionIconButton
|
||||
label="Request review"
|
||||
icon={<Eye size={11} />}
|
||||
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestReview(task.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{columnId === 'in_progress' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
/>
|
||||
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{columnId === 'review' ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{isReviewManual ? (
|
||||
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
Manual review
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{columnId === 'done' ? (
|
||||
<>
|
||||
<TaskActionIconButton
|
||||
label="Approve"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
|
|
@ -465,34 +421,84 @@ export const KanbanTaskCard = ({
|
|||
}}
|
||||
/>
|
||||
<TaskActionIconButton
|
||||
label="Request changes"
|
||||
icon={<FilePenLine size={11} />}
|
||||
variant="destructive"
|
||||
className="bg-red-500/90 text-white hover:bg-red-500"
|
||||
label="Request review"
|
||||
icon={<Eye size={11} />}
|
||||
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestChanges(task.id);
|
||||
onRequestReview(task.id);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{columnId === 'review' ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{isReviewManual ? (
|
||||
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
Manual review
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TaskActionIconButton
|
||||
label="Approve"
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprove(task.id);
|
||||
}}
|
||||
/>
|
||||
<TaskActionIconButton
|
||||
label="Request changes"
|
||||
icon={<FilePenLine size={11} />}
|
||||
variant="destructive"
|
||||
className="bg-red-500/90 text-white hover:bg-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestChanges(task.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{columnId === 'approved' ? (
|
||||
<TaskActionIconButton
|
||||
label="Disapprove"
|
||||
icon={<RotateCcw size={11} />}
|
||||
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBackToDone(task.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{columnId === 'approved' ? (
|
||||
<TaskActionIconButton
|
||||
label="Disapprove"
|
||||
icon={<RotateCcw size={11} />}
|
||||
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBackToDone(task.id);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.task === next.task &&
|
||||
prev.teamName === next.teamName &&
|
||||
prev.columnId === next.columnId &&
|
||||
prev.kanbanTaskState === next.kanbanTaskState &&
|
||||
prev.hasReviewers === next.hasReviewers &&
|
||||
prev.compact === next.compact &&
|
||||
prev.taskMap === next.taskMap &&
|
||||
prev.memberColorMap === next.memberColorMap &&
|
||||
prev.onRequestReview === next.onRequestReview &&
|
||||
prev.onApprove === next.onApprove &&
|
||||
prev.onRequestChanges === next.onRequestChanges &&
|
||||
prev.onMoveBackToDone === next.onMoveBackToDone &&
|
||||
prev.onStartTask === next.onStartTask &&
|
||||
prev.onCompleteTask === next.onCompleteTask &&
|
||||
prev.onCancelTask === next.onCancelTask &&
|
||||
prev.onScrollToTask === next.onScrollToTask &&
|
||||
prev.onTaskClick === next.onTaskClick &&
|
||||
prev.onViewChanges === next.onViewChanges &&
|
||||
prev.onDeleteTask === next.onDeleteTask
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
import { api } from '@renderer/api';
|
||||
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
|
|
@ -41,6 +46,9 @@ import type {
|
|||
UpdaterStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
|
||||
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||
|
||||
// =============================================================================
|
||||
// Store Creation
|
||||
// =============================================================================
|
||||
|
|
@ -135,15 +143,25 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
});
|
||||
const inProgressChangePresencePollTimer = setInterval(() => {
|
||||
void pollVisibleTeamInProgressChangePresence();
|
||||
}, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS);
|
||||
cleanupFns.push(() => {
|
||||
clearInterval(inProgressChangePresencePollTimer);
|
||||
});
|
||||
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let inProgressChangePresencePollInFlight = false;
|
||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||
|
||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SESSION_REFRESH_DEBOUNCE_MS = 150;
|
||||
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
|
||||
const TEAM_REFRESH_THROTTLE_MS = 800;
|
||||
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
|
||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
|
||||
|
|
@ -152,6 +170,69 @@ export function initializeNotificationListeners(): () => void {
|
|||
return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId;
|
||||
};
|
||||
|
||||
const pollVisibleTeamInProgressChangePresence = async (): Promise<void> => {
|
||||
if (inProgressChangePresencePollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = useStore.getState();
|
||||
const selectedTeamName = state.selectedTeamName;
|
||||
const selectedTeamData = state.selectedTeamData;
|
||||
if (
|
||||
!selectedTeamName ||
|
||||
!selectedTeamData ||
|
||||
selectedTeamData.teamName !== selectedTeamName ||
|
||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateTasks = selectedTeamData.tasks.filter((task) => {
|
||||
if (task.status !== 'in_progress') {
|
||||
return false;
|
||||
}
|
||||
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
||||
});
|
||||
if (candidateTasks.length === 0) {
|
||||
inProgressChangePresenceCursorByTeam.delete(selectedTeamName);
|
||||
return;
|
||||
}
|
||||
|
||||
inProgressChangePresencePollInFlight = true;
|
||||
try {
|
||||
const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0;
|
||||
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
|
||||
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
|
||||
const nextTask = sourceTasks[cursor % sourceTasks.length];
|
||||
|
||||
inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length);
|
||||
|
||||
const current = useStore.getState();
|
||||
if (
|
||||
current.selectedTeamName !== selectedTeamName ||
|
||||
!current.selectedTeamData ||
|
||||
current.selectedTeamData.teamName !== selectedTeamName ||
|
||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id);
|
||||
if (!currentTask || currentTask.status !== 'in_progress') {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOptions = buildTaskChangeRequestOptions(currentTask);
|
||||
const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions);
|
||||
current.invalidateTaskChangePresence([cacheKey]);
|
||||
await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions);
|
||||
} catch {
|
||||
// Best-effort polling for in-progress tasks only.
|
||||
} finally {
|
||||
inProgressChangePresencePollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {
|
||||
const key = `${projectId}/${sessionId}`;
|
||||
// Throttle (not trailing debounce): keep at most one pending refresh per session.
|
||||
|
|
@ -257,6 +338,61 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
};
|
||||
|
||||
const getTrackedChangePresenceTeams = (): Set<string> => {
|
||||
const { selectedTeamName, selectedTeamData } = useStore.getState();
|
||||
if (
|
||||
!selectedTeamName ||
|
||||
!selectedTeamData ||
|
||||
selectedTeamData.teamName !== selectedTeamName ||
|
||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
||||
) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set([selectedTeamName]);
|
||||
};
|
||||
|
||||
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
|
||||
let trackedTeamNames = new Set<string>();
|
||||
const syncVisibleTeamTracking = (): void => {
|
||||
const nextTrackedTeamNames = getTrackedChangePresenceTeams();
|
||||
|
||||
for (const teamName of nextTrackedTeamNames) {
|
||||
if (!trackedTeamNames.has(teamName)) {
|
||||
void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
for (const teamName of trackedTeamNames) {
|
||||
if (!nextTrackedTeamNames.has(teamName)) {
|
||||
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
trackedTeamNames = nextTrackedTeamNames;
|
||||
};
|
||||
|
||||
syncVisibleTeamTracking();
|
||||
|
||||
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
|
||||
if (
|
||||
state.paneLayout === prevState.paneLayout &&
|
||||
state.selectedTeamName === prevState.selectedTeamName &&
|
||||
state.selectedTeamData === prevState.selectedTeamData
|
||||
) {
|
||||
return;
|
||||
}
|
||||
syncVisibleTeamTracking();
|
||||
});
|
||||
|
||||
cleanupFns.push(() => {
|
||||
unsubscribeVisibleTeamTracking();
|
||||
for (const teamName of trackedTeamNames) {
|
||||
void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined);
|
||||
}
|
||||
trackedTeamNames.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||
if (api.onTodoChange) {
|
||||
const cleanup = api.onTodoChange((event) => {
|
||||
|
|
@ -474,6 +610,22 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'log-source-change') {
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamPresenceRefreshTimers.has(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamPresenceRefreshTimers.delete(event.teamName);
|
||||
const current = useStore.getState();
|
||||
void current.refreshSelectedTeamChangePresence(event.teamName);
|
||||
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
||||
teamPresenceRefreshTimers.set(event.teamName, timer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||
if (!teamListRefreshTimer) {
|
||||
teamListRefreshTimer = setTimeout(() => {
|
||||
|
|
@ -513,6 +665,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanup();
|
||||
for (const t of teamRefreshTimers.values()) clearTimeout(t);
|
||||
teamRefreshTimers = new Map();
|
||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||
teamPresenceRefreshTimers = new Map();
|
||||
if (teamListRefreshTimer) {
|
||||
clearTimeout(teamListRefreshTimer);
|
||||
teamListRefreshTimer = null;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const taskChangesPresenceRevalidationInFlight = new Set<string>();
|
|||
/** Negative results cached with timestamp — recheck after 30s */
|
||||
const taskChangesNegativeCache = new Map<string, number>();
|
||||
const NEGATIVE_CACHE_TTL = 30_000;
|
||||
const TASK_CHANGE_WARM_CONCURRENCY = 4;
|
||||
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
|
||||
let latestTaskChangesRequestToken = 0;
|
||||
|
||||
|
|
@ -77,6 +78,16 @@ function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean {
|
|||
return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME;
|
||||
}
|
||||
|
||||
function resolveTaskChangePresenceFromResult(
|
||||
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
|
||||
): 'has_changes' | 'no_changes' | null {
|
||||
if (data.files.length > 0) {
|
||||
return 'has_changes';
|
||||
}
|
||||
|
||||
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
|
||||
}
|
||||
|
||||
export interface ChangeReviewSlice {
|
||||
// Phase 1 state
|
||||
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
|
||||
|
|
@ -503,10 +514,14 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const data = await api.review.getTaskChanges(teamName, taskId, options);
|
||||
if (requestToken !== latestTaskChangesRequestToken) return;
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
||||
installActiveChangeSetForLoad(data, {
|
||||
activeTaskChangeRequestOptions: options,
|
||||
taskHasChanges: { ...get().taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||
});
|
||||
if (nextPresence) {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence);
|
||||
}
|
||||
if (data.files.length > 0) {
|
||||
taskChangesNegativeCache.delete(cacheKey);
|
||||
} else {
|
||||
|
|
@ -1310,12 +1325,20 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
taskId: string,
|
||||
options: TaskChangeRequestOptions
|
||||
) => {
|
||||
const selectedTask =
|
||||
get().selectedTeamName === teamName
|
||||
? get().selectedTeamData?.tasks.find((task) => task.id === taskId)
|
||||
: undefined;
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
|
||||
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return;
|
||||
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
|
||||
return;
|
||||
}
|
||||
if (taskChangesCheckInFlight.has(cacheKey)) return;
|
||||
const negativeTs = taskChangesNegativeCache.get(cacheKey);
|
||||
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
|
||||
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
|
||||
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return;
|
||||
|
||||
taskChangesCheckInFlight.add(cacheKey);
|
||||
try {
|
||||
|
|
@ -1323,11 +1346,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
...options,
|
||||
summaryOnly: true,
|
||||
});
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
||||
if (data.files.length > 0) {
|
||||
set((s) => ({
|
||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
|
||||
}));
|
||||
taskChangesNegativeCache.delete(cacheKey);
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
|
||||
if (wasRestoredBeforeCurrentSession(data)) {
|
||||
void revalidateTaskChangePresence(teamName, taskId, options);
|
||||
}
|
||||
|
|
@ -1336,6 +1361,11 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false },
|
||||
}));
|
||||
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||
if (nextPresence === 'no_changes') {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes');
|
||||
} else if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Allow immediate retry after transient failures (race, file lock, late logs).
|
||||
|
|
@ -1359,39 +1389,46 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
uniqueRequests.set(cacheKey, request);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...uniqueRequests.entries()].map(async ([cacheKey, request]) => {
|
||||
if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey))
|
||||
return;
|
||||
const entries = [...uniqueRequests.entries()];
|
||||
const runWarmRequest = async (
|
||||
cacheKey: string,
|
||||
request: { teamName: string; taskId: string; options: TaskChangeRequestOptions }
|
||||
): Promise<void> => {
|
||||
if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
taskChangesCheckInFlight.add(cacheKey);
|
||||
try {
|
||||
const data = await api.review.getTaskChanges(request.teamName, request.taskId, {
|
||||
...request.options,
|
||||
summaryOnly: true,
|
||||
});
|
||||
set((s) => ({
|
||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||
}));
|
||||
if (data.files.length > 0) {
|
||||
taskChangesNegativeCache.delete(cacheKey);
|
||||
if (wasRestoredBeforeCurrentSession(data)) {
|
||||
void revalidateTaskChangePresence(
|
||||
request.teamName,
|
||||
request.taskId,
|
||||
request.options
|
||||
);
|
||||
}
|
||||
} else {
|
||||
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||
taskChangesCheckInFlight.add(cacheKey);
|
||||
try {
|
||||
const data = await api.review.getTaskChanges(request.teamName, request.taskId, {
|
||||
...request.options,
|
||||
summaryOnly: true,
|
||||
});
|
||||
set((s) => ({
|
||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||
}));
|
||||
if (data.files.length > 0) {
|
||||
taskChangesNegativeCache.delete(cacheKey);
|
||||
if (wasRestoredBeforeCurrentSession(data)) {
|
||||
void revalidateTaskChangePresence(request.teamName, request.taskId, request.options);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort warm path.
|
||||
} finally {
|
||||
taskChangesCheckInFlight.delete(cacheKey);
|
||||
} else {
|
||||
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Best-effort warm path.
|
||||
} finally {
|
||||
taskChangesCheckInFlight.delete(cacheKey);
|
||||
}
|
||||
};
|
||||
|
||||
for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) {
|
||||
await Promise.all(
|
||||
entries
|
||||
.slice(index, index + TASK_CHANGE_WARM_CONCURRENCY)
|
||||
.map(([cacheKey, request]) => runWarmRequest(cacheKey, request))
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
invalidateTaskChangePresence: (cacheKeys) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
isTaskSummaryCacheableForOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
|
|
@ -57,6 +57,43 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
|||
});
|
||||
}
|
||||
|
||||
async function refreshTaskChangePresenceForUpdatedTask(
|
||||
getState: () => AppState,
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<void> {
|
||||
const state = getState();
|
||||
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = state.selectedTeamData.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = buildTaskChangeRequestOptions(task);
|
||||
if (!canDisplayTaskChangesForOptions(options)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.invalidateTaskChangePresence !== 'function' ||
|
||||
typeof state.checkTaskHasChanges !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||
state.invalidateTaskChangePresence([cacheKey]);
|
||||
|
||||
try {
|
||||
await state.checkTaskHasChanges(teamName, taskId, options);
|
||||
} catch {
|
||||
// Best-effort refresh after explicit task transition.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollProvisioningStatus(
|
||||
getState: () => TeamSlice,
|
||||
runId: string,
|
||||
|
|
@ -105,6 +142,7 @@ import type {
|
|||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
TaskChangePresenceState,
|
||||
TeamCreateRequest,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
|
|
@ -445,19 +483,6 @@ function collectTaskChangeInvalidationState(
|
|||
};
|
||||
}
|
||||
|
||||
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 : '';
|
||||
|
|
@ -556,6 +581,12 @@ export interface TeamSlice {
|
|||
openTeamsTab: () => void;
|
||||
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
|
||||
clearKanbanFilter: () => void;
|
||||
setSelectedTeamTaskChangePresence: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
presence: TaskChangePresenceState
|
||||
) => void;
|
||||
refreshSelectedTeamChangePresence: (teamName: string) => Promise<void>;
|
||||
selectTeam: (
|
||||
teamName: string,
|
||||
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
|
||||
|
|
@ -1099,6 +1130,89 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set({ kanbanFilterQuery: null });
|
||||
},
|
||||
|
||||
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
|
||||
set((state) => {
|
||||
let selectedChanged = false;
|
||||
const nextSelectedTeamData =
|
||||
state.selectedTeamName === teamName && state.selectedTeamData
|
||||
? {
|
||||
...state.selectedTeamData,
|
||||
tasks: state.selectedTeamData.tasks.map((task) => {
|
||||
if (task.id !== taskId || task.changePresence === presence) {
|
||||
return task;
|
||||
}
|
||||
selectedChanged = true;
|
||||
return { ...task, changePresence: presence };
|
||||
}),
|
||||
}
|
||||
: state.selectedTeamData;
|
||||
|
||||
let globalChanged = false;
|
||||
const nextGlobalTasks = state.globalTasks.map((task) => {
|
||||
if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) {
|
||||
return task;
|
||||
}
|
||||
globalChanged = true;
|
||||
return { ...task, changePresence: presence };
|
||||
});
|
||||
|
||||
if (!selectedChanged && !globalChanged) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}),
|
||||
...(globalChanged ? { globalTasks: nextGlobalTasks } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refreshSelectedTeamChangePresence: async (teamName: string) => {
|
||||
const selected = get().selectedTeamData;
|
||||
if (get().selectedTeamName !== teamName || !selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () =>
|
||||
api.teams.getTaskChangePresence(teamName)
|
||||
);
|
||||
|
||||
if (get().selectedTeamName !== teamName || !get().selectedTeamData) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextTasks = state.selectedTeamData.tasks.map((task) => {
|
||||
const nextPresence = presenceByTaskId[task.id] ?? 'unknown';
|
||||
if (task.changePresence === nextPresence) {
|
||||
return task;
|
||||
}
|
||||
changed = true;
|
||||
return { ...task, changePresence: nextPresence };
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTeamData: {
|
||||
...state.selectedTeamData,
|
||||
tasks: nextTasks,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// best-effort lightweight refresh; keep current UI state on failure
|
||||
}
|
||||
},
|
||||
|
||||
selectTeam: async (teamName: string, opts) => {
|
||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||
|
|
@ -1170,11 +1284,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
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;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
|
|
@ -1295,10 +1404,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
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;
|
||||
|
|
@ -1424,6 +1529,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set({ reviewActionError: null });
|
||||
await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId));
|
||||
await get().refreshTeamData(teamName);
|
||||
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
|
||||
} catch (error) {
|
||||
set({
|
||||
reviewActionError: mapReviewError(error),
|
||||
|
|
@ -1441,6 +1547,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
startTask: async (teamName: string, taskId: string) => {
|
||||
const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
|
||||
await get().refreshTeamData(teamName);
|
||||
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
|
@ -1449,6 +1556,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
api.teams.startTaskByUser(teamName, taskId)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
|
@ -1457,6 +1565,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
api.teams.updateTaskStatus(teamName, taskId, status)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId);
|
||||
},
|
||||
|
||||
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TaskChangePresenceState,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -416,6 +417,8 @@ export interface HttpServerAPI {
|
|||
export interface TeamsAPI {
|
||||
list: () => Promise<TeamSummary[]>;
|
||||
getData: (teamName: string) => Promise<TeamData>;
|
||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
restoreTeam: (teamName: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -218,11 +218,15 @@ export interface TeamTask {
|
|||
}
|
||||
|
||||
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||
export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown';
|
||||
|
||||
export interface TeamTaskWithKanban extends TeamTask {
|
||||
/** Set when task is in team kanban (review or approved column). */
|
||||
kanbanColumn?: 'review' | 'approved';
|
||||
/** Reviewer assigned in kanban state, when applicable. */
|
||||
reviewer?: string | null;
|
||||
/** Cheap persisted change-presence state for kanban rendering. */
|
||||
changePresence?: TaskChangePresenceState;
|
||||
}
|
||||
|
||||
/** Metadata for an attachment associated with a task or comment. */
|
||||
|
|
@ -502,6 +506,7 @@ export interface TeamChangeEvent {
|
|||
type:
|
||||
| 'config'
|
||||
| 'inbox'
|
||||
| 'log-source-change'
|
||||
| 'task'
|
||||
| 'lead-activity'
|
||||
| 'lead-context'
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
|
|
@ -56,6 +57,7 @@ import {
|
|||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_TASK_CHANGE_PRESENCE,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -105,7 +107,9 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
|
|
@ -175,6 +179,8 @@ describe('ipc teams handlers', () => {
|
|||
it('registers all expected handlers', () => {
|
||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
||||
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
||||
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
||||
expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true);
|
||||
expect(handlers.has(TEAM_CREATE)).toBe(true);
|
||||
|
|
@ -224,6 +230,32 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates change presence tracking for a team', async () => {
|
||||
const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const result = (await handler!({} as never, 'my-team', true)) as {
|
||||
success: boolean;
|
||||
data?: void;
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.setTaskChangePresenceTracking).toHaveBeenCalledWith('my-team', true);
|
||||
});
|
||||
|
||||
it('returns lightweight task change presence for a team', async () => {
|
||||
const handler = handlers.get(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const result = (await handler!({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(result).toEqual({ success: true, data: { 'task-1': 'has_changes' } });
|
||||
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('returns success false on invalid sendMessage args', async () => {
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import * as fs from 'fs/promises';
|
||||
|
||||
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
const TEAM_NAME = 'team-a';
|
||||
|
|
@ -70,31 +71,134 @@ function persistedEntryPath(baseDir: string): string {
|
|||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeTaskChangeResult(
|
||||
taskId = TASK_ID,
|
||||
overrides: Partial<{
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
confidence: 'high' | 'medium' | 'low' | 'fallback';
|
||||
content: string;
|
||||
warning: string;
|
||||
}> = {}
|
||||
) {
|
||||
const teamName = overrides.teamName ?? TEAM_NAME;
|
||||
const targetTaskId = overrides.taskId ?? taskId;
|
||||
const filePath = overrides.filePath ?? '/repo/src/file.ts';
|
||||
const content = overrides.content ?? 'export const value = 1;\n';
|
||||
const confidence = overrides.confidence ?? 'high';
|
||||
const confidenceTierByLabel = {
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
fallback: 4,
|
||||
} as const;
|
||||
const files =
|
||||
content.length > 0
|
||||
? [
|
||||
{
|
||||
filePath,
|
||||
relativePath: 'src/file.ts',
|
||||
snippets: [],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
teamName,
|
||||
taskId: targetTaskId,
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
||||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
||||
confidence,
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: targetTaskId,
|
||||
memberName: 'alice',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: files.map((file) => file.filePath),
|
||||
confidence: {
|
||||
tier: confidenceTierByLabel[confidence],
|
||||
label: confidence,
|
||||
reason: 'test fixture',
|
||||
},
|
||||
},
|
||||
warnings: overrides.warning ? [overrides.warning] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function createService(params: {
|
||||
logPaths: string[];
|
||||
projectPath?: string;
|
||||
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
|
||||
teamLogSourceTracker?: {
|
||||
ensureTracking: ReturnType<
|
||||
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
||||
>;
|
||||
};
|
||||
taskChangeWorkerClient?: {
|
||||
isAvailable: ReturnType<typeof vi.fn<() => boolean>>;
|
||||
computeTaskChanges: ReturnType<typeof vi.fn<() => Promise<unknown>>>;
|
||||
};
|
||||
}) {
|
||||
const findLogFileRefsForTask =
|
||||
params.findLogFileRefsForTask ??
|
||||
vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' })));
|
||||
const taskChangeWorkerClient =
|
||||
params.taskChangeWorkerClient ??
|
||||
({
|
||||
isAvailable: vi.fn(() => false),
|
||||
computeTaskChanges: vi.fn(async () => {
|
||||
throw new Error('worker disabled in test');
|
||||
}),
|
||||
} as const);
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
findLogFileRefsForTask,
|
||||
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,
|
||||
undefined,
|
||||
taskChangeWorkerClient as any
|
||||
);
|
||||
|
||||
if (params.taskChangePresenceRepository && params.teamLogSourceTracker) {
|
||||
service.setTaskChangePresenceServices(
|
||||
params.taskChangePresenceRepository as any,
|
||||
params.teamLogSourceTracker as any
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
findLogFileRefsForTask,
|
||||
service: new ChangeExtractorService(
|
||||
{
|
||||
findLogFileRefsForTask,
|
||||
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
|
||||
),
|
||||
service,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -337,7 +441,14 @@ describe('ChangeExtractorService', () => {
|
|||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any
|
||||
{ getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any,
|
||||
undefined,
|
||||
{
|
||||
isAvailable: vi.fn(() => false),
|
||||
computeTaskChanges: vi.fn(async () => {
|
||||
throw new Error('worker disabled in test');
|
||||
}),
|
||||
} as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
|
@ -373,4 +484,265 @@ describe('ChangeExtractorService', () => {
|
|||
expect(result.files[0]?.relativePath).toBe('src/same.ts');
|
||||
expect(result.totalLinesAdded).toBe(2);
|
||||
});
|
||||
|
||||
it('prefers worker task-change results when the worker is available', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const workerResult = makeTaskChangeResult();
|
||||
const computeTaskChanges = vi.fn(async () => workerResult);
|
||||
const { service, findLogFileRefsForTask } = createService({
|
||||
logPaths: [],
|
||||
taskChangeWorkerClient: {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result).toEqual(workerResult);
|
||||
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back inline when task-change worker is unavailable', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn();
|
||||
const { service, findLogFileRefsForTask } = createService({
|
||||
logPaths: [logPath],
|
||||
taskChangeWorkerClient: {
|
||||
isAvailable: vi.fn(() => false),
|
||||
computeTaskChanges,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(findLogFileRefsForTask).toHaveBeenCalled();
|
||||
expect(computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back inline when task-change worker throws', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => {
|
||||
throw new Error('worker failed');
|
||||
});
|
||||
const { service, findLogFileRefsForTask } = createService({
|
||||
logPaths: [logPath],
|
||||
taskChangeWorkerClient: {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
expect(findLogFileRefsForTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
|
||||
const { service } = createService({
|
||||
logPaths: [logPath],
|
||||
taskChangeWorkerClient: {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges,
|
||||
},
|
||||
});
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores persisted summaries without invoking worker compute', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const firstWorker = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
|
||||
};
|
||||
await createService({
|
||||
logPaths: [logPath],
|
||||
taskChangeWorkerClient: firstWorker,
|
||||
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
const secondWorker = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })),
|
||||
};
|
||||
const restored = await createService({
|
||||
logPaths: [logPath],
|
||||
taskChangeWorkerClient: secondWorker,
|
||||
}).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(restored.files).toHaveLength(1);
|
||||
expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let stale worker results populate summary cache after invalidation', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const first = deferred<ReturnType<typeof makeTaskChangeResult>>();
|
||||
const worker = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' })
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangeWorkerClient: worker,
|
||||
});
|
||||
|
||||
const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true });
|
||||
const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
first.resolve(makeTaskChangeResult());
|
||||
const stale = await stalePromise;
|
||||
const fresh = await freshPromise;
|
||||
|
||||
expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts');
|
||||
expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts');
|
||||
expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('writes has_changes presence entries after successful task diff computation', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-presence.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const upsertEntry = vi.fn(async () => undefined);
|
||||
const ensureTracking = vi.fn(async () => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult()),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [logPath],
|
||||
taskChangePresenceRepository: { upsertEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(upsertEntry).toHaveBeenCalledWith(
|
||||
TEAM_NAME,
|
||||
expect.objectContaining({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
taskId: TASK_ID,
|
||||
presence: 'has_changes',
|
||||
taskSignature: buildTaskChangePresenceDescriptor({
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
intervals: [
|
||||
{
|
||||
startedAt: '2026-03-01T10:00:00.000Z',
|
||||
completedAt: '2026-03-01T10:10:00.000Z',
|
||||
},
|
||||
],
|
||||
reviewState: 'none',
|
||||
historyEvents: [],
|
||||
}).taskSignature,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not write no_changes presence entries for uncertain empty task diff results', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const upsertEntry = vi.fn(async () => undefined);
|
||||
const ensureTracking = vi.fn(async () => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
255
test/main/services/team/TaskChangeWorkerClient.test.ts
Normal file
255
test/main/services/team/TaskChangeWorkerClient.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TaskChangeWorkerClient } from '../../../../src/main/services/team/TaskChangeWorkerClient';
|
||||
|
||||
import type { TaskChangeSetV2 } from '../../../../src/shared/types';
|
||||
import type { TaskChangeWorkerRequest, TaskChangeWorkerResponse } from '../../../../src/main/services/team/taskChangeWorkerTypes';
|
||||
|
||||
class FakeWorker {
|
||||
readonly posted: TaskChangeWorkerRequest[] = [];
|
||||
readonly terminate = vi.fn(async () => 0);
|
||||
private readonly listeners: {
|
||||
message: Array<(message: TaskChangeWorkerResponse) => void>;
|
||||
error: Array<(error: Error) => void>;
|
||||
exit: Array<(code: number) => void>;
|
||||
} = {
|
||||
message: [],
|
||||
error: [],
|
||||
exit: [],
|
||||
};
|
||||
|
||||
on(event: 'message' | 'error' | 'exit', listener: ((value: any) => void) & ((value: any) => void)) {
|
||||
if (event === 'message') this.listeners.message.push(listener as (message: TaskChangeWorkerResponse) => void);
|
||||
if (event === 'error') this.listeners.error.push(listener as (error: Error) => void);
|
||||
if (event === 'exit') this.listeners.exit.push(listener as (code: number) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
postMessage(message: TaskChangeWorkerRequest): void {
|
||||
this.posted.push(message);
|
||||
}
|
||||
|
||||
emitMessage(message: TaskChangeWorkerResponse): void {
|
||||
for (const listener of this.listeners.message) {
|
||||
listener(message);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(error: Error): void {
|
||||
for (const listener of this.listeners.error) {
|
||||
listener(error);
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(code: number): void {
|
||||
for (const listener of this.listeners.exit) {
|
||||
listener(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makePayload(taskId = 'task-1') {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
taskId,
|
||||
taskMeta: {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
|
||||
reviewState: 'none' as const,
|
||||
historyEvents: [],
|
||||
},
|
||||
effectiveOptions: {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
|
||||
},
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult(taskId = 'task-1', filePath = '/repo/src/file.ts'): TaskChangeSetV2 {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
taskId,
|
||||
files: [
|
||||
{
|
||||
filePath,
|
||||
relativePath: 'src/file.ts',
|
||||
snippets: [],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
},
|
||||
],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'high' as const,
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: 'alice',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [filePath],
|
||||
confidence: { tier: 1, label: 'high', reason: 'test fixture' },
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskChangeWorkerClient', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves successful worker responses', async () => {
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: '/tmp/task-change-worker.cjs',
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker as any;
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const promise = client.computeTaskChanges(makePayload());
|
||||
const request = workers[0]!.posted[0]!;
|
||||
workers[0]!.emitMessage({ id: request.id, ok: true, result: makeResult() });
|
||||
|
||||
await expect(promise).resolves.toEqual(makeResult());
|
||||
});
|
||||
|
||||
it('times out the active request, terminates the worker, and recreates it on the next call', async () => {
|
||||
vi.useFakeTimers();
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: '/tmp/task-change-worker.cjs',
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker as any;
|
||||
},
|
||||
timeoutMs: 25,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const firstPromise = client.computeTaskChanges(makePayload('task-timeout'));
|
||||
const firstExpectation = expect(firstPromise).rejects.toThrow('Worker call timeout');
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await firstExpectation;
|
||||
expect(workers[0]!.terminate).toHaveBeenCalledTimes(1);
|
||||
|
||||
const secondPromise = client.computeTaskChanges(makePayload('task-next'));
|
||||
const request = workers[1]!.posted[0]!;
|
||||
workers[1]!.emitMessage({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: makeResult('task-next', '/repo/src/next.ts'),
|
||||
});
|
||||
|
||||
await expect(secondPromise).resolves.toEqual(makeResult('task-next', '/repo/src/next.ts'));
|
||||
expect(workers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects all pending requests on worker error and clears queued work', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: '/tmp/task-change-worker.cjs',
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker as any;
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const first = client.computeTaskChanges(makePayload('task-1'));
|
||||
const second = client.computeTaskChanges(makePayload('task-2'));
|
||||
workers[0]!.emitError(new Error('boom'));
|
||||
|
||||
await expect(first).rejects.toThrow('boom');
|
||||
await expect(second).rejects.toThrow('boom');
|
||||
|
||||
const third = client.computeTaskChanges(makePayload('task-3'));
|
||||
const request = workers[1]!.posted[0]!;
|
||||
workers[1]!.emitMessage({ id: request.id, ok: true, result: makeResult('task-3') });
|
||||
await expect(third).resolves.toEqual(makeResult('task-3'));
|
||||
});
|
||||
|
||||
it('rejects all pending requests on worker exit', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: '/tmp/task-change-worker.cjs',
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker as any;
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const first = client.computeTaskChanges(makePayload('task-1'));
|
||||
const second = client.computeTaskChanges(makePayload('task-2'));
|
||||
workers[0]!.emitExit(9);
|
||||
|
||||
await expect(first).rejects.toThrow('Worker exited with code 9');
|
||||
await expect(second).rejects.toThrow('Worker exited with code 9');
|
||||
});
|
||||
|
||||
it('executes queued requests sequentially in FIFO order', async () => {
|
||||
const workers: FakeWorker[] = [];
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: '/tmp/task-change-worker.cjs',
|
||||
workerFactory: () => {
|
||||
const worker = new FakeWorker();
|
||||
workers.push(worker);
|
||||
return worker as any;
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const first = client.computeTaskChanges(makePayload('task-1'));
|
||||
const second = client.computeTaskChanges(makePayload('task-2'));
|
||||
|
||||
expect(workers[0]!.posted).toHaveLength(1);
|
||||
expect(workers[0]!.posted[0]!.payload.taskId).toBe('task-1');
|
||||
|
||||
workers[0]!.emitMessage({
|
||||
id: workers[0]!.posted[0]!.id,
|
||||
ok: true,
|
||||
result: makeResult('task-1'),
|
||||
});
|
||||
|
||||
expect(workers[0]!.posted).toHaveLength(2);
|
||||
expect(workers[0]!.posted[1]!.payload.taskId).toBe('task-2');
|
||||
|
||||
workers[0]!.emitMessage({
|
||||
id: workers[0]!.posted[1]!.id,
|
||||
ok: true,
|
||||
result: makeResult('task-2'),
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual(makeResult('task-1'));
|
||||
await expect(second).resolves.toEqual(makeResult('task-2'));
|
||||
});
|
||||
|
||||
it('reports unavailable when the worker file is missing', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new TaskChangeWorkerClient({
|
||||
workerPath: null,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(client.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||
|
||||
import type { TeamTask } from '../../../../src/shared/types/team';
|
||||
|
|
@ -145,6 +146,51 @@ describe('TeamDataService', () => {
|
|||
expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' });
|
||||
});
|
||||
|
||||
it('starts and stops task change presence tracking outside getTeamData', async () => {
|
||||
const ensureTracking = vi.fn(async () => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'generation-1',
|
||||
}));
|
||||
const stopTracking = vi.fn(async () => undefined);
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never
|
||||
);
|
||||
|
||||
service.setTaskChangePresenceServices(
|
||||
{
|
||||
load: vi.fn(async () => null),
|
||||
save: vi.fn(async () => undefined),
|
||||
deleteTasks: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
ensureTracking,
|
||||
stopTracking,
|
||||
} as never
|
||||
);
|
||||
|
||||
service.setTaskChangePresenceTracking('my-team', true);
|
||||
service.setTaskChangePresenceTracking('my-team', false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(ensureTracking).toHaveBeenCalledWith('my-team');
|
||||
expect(stopTracking).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('surfaces controller reconcile failures', async () => {
|
||||
const reconcileArtifacts = vi.fn(() => {
|
||||
throw new Error('reconcile failed');
|
||||
|
|
@ -1662,4 +1708,241 @@ describe('TeamDataService', () => {
|
|||
else process.env[TASK_COMMENT_FORWARDING_ENV] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns unknown changePresence when no cached presence entry exists', async () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-1',
|
||||
subject: 'Review API',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
};
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
} as never
|
||||
);
|
||||
|
||||
const load = vi.fn(async () => null);
|
||||
|
||||
service.setTaskChangePresenceServices(
|
||||
{
|
||||
load,
|
||||
upsertEntry: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
ensureTracking: vi.fn(async () => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})),
|
||||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
|
||||
expect(data.tasks[0]?.changePresence).toBe('unknown');
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns cached changePresence only when signature and generation still match', async () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-1',
|
||||
subject: 'Review API',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
};
|
||||
const descriptor = buildTaskChangePresenceDescriptor({
|
||||
owner: task.owner,
|
||||
status: task.status,
|
||||
intervals: task.workIntervals,
|
||||
historyEvents: task.historyEvents,
|
||||
reviewState: 'none',
|
||||
});
|
||||
|
||||
const createServiceWithPresence = (
|
||||
load: ReturnType<typeof vi.fn>,
|
||||
trackerSnapshot: { projectFingerprint: string; logSourceGeneration: string } | null
|
||||
) => {
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
} as never
|
||||
);
|
||||
|
||||
service.setTaskChangePresenceServices(
|
||||
{
|
||||
load,
|
||||
upsertEntry: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
getSnapshot: vi.fn(() => trackerSnapshot),
|
||||
ensureTracking: vi.fn(async () => trackerSnapshot),
|
||||
} as never
|
||||
);
|
||||
|
||||
return service;
|
||||
};
|
||||
|
||||
const matched = await createServiceWithPresence(
|
||||
vi.fn(async () => ({
|
||||
version: 1,
|
||||
teamName: 'my-team',
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
entries: {
|
||||
'task-1': {
|
||||
taskId: 'task-1',
|
||||
taskSignature: descriptor.taskSignature,
|
||||
presence: 'has_changes',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
logSourceGeneration: 'log-generation',
|
||||
},
|
||||
},
|
||||
})),
|
||||
{
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}
|
||||
).getTeamData('my-team');
|
||||
expect(matched.tasks[0]?.changePresence).toBe('has_changes');
|
||||
|
||||
const mismatched = await createServiceWithPresence(
|
||||
vi.fn(async () => ({
|
||||
version: 1,
|
||||
teamName: 'my-team',
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'stale-generation',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
entries: {
|
||||
'task-1': {
|
||||
taskId: 'task-1',
|
||||
taskSignature: descriptor.taskSignature,
|
||||
presence: 'has_changes',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
logSourceGeneration: 'stale-generation',
|
||||
},
|
||||
},
|
||||
})),
|
||||
{
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}
|
||||
).getTeamData('my-team');
|
||||
expect(mismatched.tasks[0]?.changePresence).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns lightweight task change presence without loading full team data', async () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-1',
|
||||
subject: 'Review API',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
};
|
||||
const descriptor = buildTaskChangePresenceDescriptor({
|
||||
owner: task.owner,
|
||||
status: task.status,
|
||||
intervals: task.workIntervals,
|
||||
historyEvents: task.historyEvents,
|
||||
reviewState: 'none',
|
||||
});
|
||||
const getMessages = vi.fn(async () => []);
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages,
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
} as never
|
||||
);
|
||||
|
||||
service.setTaskChangePresenceServices(
|
||||
{
|
||||
load: vi.fn(async () => ({
|
||||
version: 1,
|
||||
teamName: 'my-team',
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
entries: {
|
||||
'task-1': {
|
||||
taskId: 'task-1',
|
||||
taskSignature: descriptor.taskSignature,
|
||||
presence: 'has_changes',
|
||||
writtenAt: '2026-03-01T12:00:00.000Z',
|
||||
logSourceGeneration: 'log-generation',
|
||||
},
|
||||
},
|
||||
})),
|
||||
upsertEntry: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
getSnapshot: vi.fn(() => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})),
|
||||
ensureTracking: vi.fn(async () => ({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})),
|
||||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTaskChangePresence('my-team');
|
||||
|
||||
expect(data).toEqual({ 'task-1': 'has_changes' });
|
||||
expect(getMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ vi.mock('@renderer/api', () => ({
|
|||
function createSliceStore() {
|
||||
return create<any>()((set, get, store) => ({
|
||||
...createChangeReviewSlice(set as never, get as never, store as never),
|
||||
setSelectedTeamTaskChangePresence: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +204,164 @@ describe('changeReviewSlice task changes', () => {
|
|||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('updates selected team task changePresence after a positive summary check', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit'));
|
||||
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-hit', OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'presence-hit',
|
||||
'has_changes'
|
||||
);
|
||||
});
|
||||
|
||||
it('updates selected team task changePresence to no_changes only for confirmed empty summaries', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
teamName: 'team-a',
|
||||
taskId: 'presence-empty',
|
||||
confidence: 'high',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: 'presence-empty',
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 1, label: 'high', reason: 'test fixture' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-empty', OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'presence-empty',
|
||||
'no_changes'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps changePresence unknown for fallback empty summaries', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
teamName: 'team-a',
|
||||
taskId: 'presence-unknown',
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: 'presence-unknown',
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-unknown', OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'presence-unknown',
|
||||
'no_changes'
|
||||
);
|
||||
});
|
||||
|
||||
it('downgrades stale known presence to unknown for fallback empty summaries', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
selectedTeamName: 'team-a',
|
||||
selectedTeamData: {
|
||||
tasks: [{ id: 'presence-stale', changePresence: 'has_changes' }],
|
||||
},
|
||||
});
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
teamName: 'team-a',
|
||||
taskId: 'presence-stale',
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: 'presence-stale',
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-stale', OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'presence-stale',
|
||||
'unknown'
|
||||
);
|
||||
});
|
||||
|
||||
it('bypasses stale negative cache when selected team task presence is unknown', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
teamName: 'team-a',
|
||||
taskId: 'presence-bypass',
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: 'presence-bypass',
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'test fixture' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A);
|
||||
store.setState({
|
||||
selectedTeamName: 'team-a',
|
||||
selectedTeamData: {
|
||||
tasks: [{ id: 'presence-bypass', changePresence: 'unknown' }],
|
||||
},
|
||||
});
|
||||
await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A);
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => {
|
||||
const store = createSliceStore();
|
||||
const first = deferred<any>();
|
||||
|
|
@ -399,6 +558,85 @@ describe('changeReviewSlice task changes', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warms task summaries with bounded concurrency', async () => {
|
||||
const store = createSliceStore();
|
||||
const pending = Array.from({ length: 6 }, () => deferred<any>());
|
||||
let callIndex = 0;
|
||||
hoisted.getTaskChanges.mockImplementation(() => pending[callIndex++].promise);
|
||||
|
||||
const requests = Array.from({ length: 6 }, (_, index) => ({
|
||||
teamName: 'team-a',
|
||||
taskId: `task-${index}`,
|
||||
options: {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
intervals: [{ startedAt: `2026-03-01T1${index}:00:00.000Z` }],
|
||||
since: `2026-03-01T0${index}:58:00.000Z`,
|
||||
stateBucket: 'completed' as const,
|
||||
},
|
||||
}));
|
||||
|
||||
const warmPromise = store.getState().warmTaskChangeSummaries(requests);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(4);
|
||||
|
||||
for (let index = 0; index < 4; index++) {
|
||||
pending[index].resolve({
|
||||
teamName: 'team-a',
|
||||
taskId: `task-${index}`,
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-12-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: `task-${index}`,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(6);
|
||||
|
||||
for (let index = 4; index < 6; index++) {
|
||||
pending[index].resolve({
|
||||
teamName: 'team-a',
|
||||
taskId: `task-${index}`,
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-12-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: `task-${index}`,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
|
||||
await warmPromise;
|
||||
});
|
||||
|
||||
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-revalidate';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ vi.mock('@renderer/api', () => ({
|
|||
})),
|
||||
},
|
||||
teams: {
|
||||
setChangePresenceTracking: vi.fn(async () => undefined),
|
||||
onTeamChange: vi.fn(
|
||||
(cb: (event: unknown, data: { teamName: string }) => void): (() => void) => {
|
||||
hoisted.onTeamChangeCb = cb;
|
||||
|
|
@ -58,6 +59,7 @@ vi.mock('@renderer/api', () => ({
|
|||
}));
|
||||
|
||||
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
describe('team change throttling', () => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
|
@ -66,10 +68,14 @@ describe('team change throttling', () => {
|
|||
vi.useFakeTimers();
|
||||
const fetchTeams = vi.fn(async () => undefined);
|
||||
const refreshTeamData = vi.fn(async () => undefined);
|
||||
const refreshSelectedTeamChangePresence = vi.fn(async () => undefined);
|
||||
|
||||
useStore.setState({
|
||||
fetchTeams,
|
||||
refreshTeamData,
|
||||
refreshSelectedTeamChangePresence,
|
||||
selectedTeamName: null,
|
||||
selectedTeamData: null,
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
|
|
@ -165,6 +171,99 @@ describe('team change throttling', () => {
|
|||
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('log-source-change refreshes only task change presence', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
const refreshSelectedTeamChangePresenceSpy = vi.spyOn(
|
||||
state,
|
||||
'refreshSelectedTeamChangePresence'
|
||||
);
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(399);
|
||||
expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => {
|
||||
const invalidateTaskChangePresence = vi.fn();
|
||||
const checkTaskHasChanges = vi.fn(async () => undefined);
|
||||
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-03-01T10:00:00.000Z',
|
||||
updatedAt: '2026-03-01T10:00:00.000Z',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||
historyEvents: [],
|
||||
reviewState: 'none',
|
||||
changePresence: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-03-01T10:00:00.000Z',
|
||||
updatedAt: '2026-03-01T10:00:00.000Z',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||
historyEvents: [],
|
||||
reviewState: 'none',
|
||||
changePresence: 'unknown',
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
invalidateTaskChangePresence,
|
||||
checkTaskHasChanges,
|
||||
} as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'my-team',
|
||||
'task-1',
|
||||
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'my-team',
|
||||
'task-2',
|
||||
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
|
||||
);
|
||||
});
|
||||
|
||||
it('per-team throttling: busy team does not block another visible team', async () => {
|
||||
// Add a second visible team tab
|
||||
useStore.setState({
|
||||
|
|
@ -204,4 +303,48 @@ describe('team change throttling', () => {
|
|||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team');
|
||||
});
|
||||
|
||||
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
||||
const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking);
|
||||
setChangePresenceTrackingSpy.mockClear();
|
||||
|
||||
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
||||
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: {
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
||||
|
||||
useStore.setState({
|
||||
selectedTeamName: 'other-team',
|
||||
selectedTeamData: null,
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
{
|
||||
id: 'p1',
|
||||
widthFraction: 1,
|
||||
tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }],
|
||||
activeTabId: 't2',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,6 +144,30 @@ describe('teamSlice actions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not warm task-change summaries on team open', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.getData.mockResolvedValue({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'completed-1',
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
createdAt: '2026-03-20T08:00:00.000Z',
|
||||
updatedAt: '2026-03-20T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
});
|
||||
|
||||
await store.getState().selectTeam('my-team');
|
||||
|
||||
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('refreshTeamData provisioning safety', () => {
|
||||
it('does not set fatal error on TEAM_PROVISIONING', async () => {
|
||||
const store = createSliceStore();
|
||||
|
|
@ -265,7 +289,7 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().selectedTeamError).toBe('Team not found');
|
||||
});
|
||||
|
||||
it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => {
|
||||
it('invalidates changed task summaries without warming task availability on refresh', async () => {
|
||||
const store = createSliceStore();
|
||||
const invalidateTaskChangePresence = vi.fn();
|
||||
const warmTaskChangeSummaries = vi.fn(async () => undefined);
|
||||
|
|
@ -367,9 +391,7 @@ describe('teamSlice actions', () => {
|
|||
|
||||
expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']);
|
||||
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
|
||||
expect(warmTaskChangeSummaries).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }),
|
||||
]);
|
||||
expect(warmTaskChangeSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue