fix(team): watch task log freshness from team state
This commit is contained in:
parent
eb83ac92b2
commit
064f5b977d
7 changed files with 178 additions and 76 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { watch } from 'chokidar';
|
import { watch } from 'chokidar';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
BOARD_TASK_CHANGES_DIRNAME,
|
BOARD_TASK_CHANGES_DIRNAME,
|
||||||
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
|
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
|
||||||
BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX,
|
BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX,
|
||||||
|
TEAM_TASK_LOG_FRESHNESS_DIRNAME,
|
||||||
classifyLogSourceWatcherEvent,
|
classifyLogSourceWatcherEvent,
|
||||||
getRelativeLogSourceParts,
|
getRelativeLogSourceParts,
|
||||||
isAgentTranscriptFileName,
|
isAgentTranscriptFileName,
|
||||||
|
|
@ -86,6 +88,23 @@ function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeamTaskLogFreshnessDir(teamName: string): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, TEAM_TASK_LOG_FRESHNESS_DIRNAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathsOverlap(left: string, right: string): boolean {
|
||||||
|
const normalizedLeft = path.normalize(left);
|
||||||
|
const normalizedRight = path.normalize(right);
|
||||||
|
const leftToRight = path.relative(normalizedLeft, normalizedRight);
|
||||||
|
const rightToLeft = path.relative(normalizedRight, normalizedLeft);
|
||||||
|
return (
|
||||||
|
!leftToRight ||
|
||||||
|
(!leftToRight.startsWith('..') && !path.isAbsolute(leftToRight)) ||
|
||||||
|
!rightToLeft ||
|
||||||
|
(!rightToLeft.startsWith('..') && !path.isAbsolute(rightToLeft))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldIgnoreLogSourceWatcherPath(
|
export function shouldIgnoreLogSourceWatcherPath(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
watchedPath: string,
|
watchedPath: string,
|
||||||
|
|
@ -382,19 +401,23 @@ export class TeamLogSourceTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context);
|
const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context);
|
||||||
const taskFreshnessWatchRootDirs = await this.ensureLogSourceFreshnessDirs(
|
const taskFreshnessDirs = await this.ensureLogSourceFreshnessDirs(
|
||||||
|
teamName,
|
||||||
context.projectDir,
|
context.projectDir,
|
||||||
taskFreshnessRootDirs
|
taskFreshnessRootDirs
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
|
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
|
||||||
return [path.normalize(context.projectDir)];
|
return {
|
||||||
|
legacyRootDirs: [path.normalize(context.projectDir)],
|
||||||
|
logSignalDirs: [getTeamTaskLogFreshnessDir(teamName)],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
||||||
context.projectDir,
|
context.projectDir,
|
||||||
context.watchSessionIds,
|
context.watchSessionIds,
|
||||||
this.getPendingUnknownSessionIds(state),
|
this.getPendingUnknownSessionIds(state),
|
||||||
taskFreshnessWatchRootDirs
|
taskFreshnessDirs
|
||||||
);
|
);
|
||||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -406,11 +429,19 @@ export class TeamLogSourceTracker {
|
||||||
ignorePermissionErrors: true,
|
ignorePermissionErrors: true,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
ignored: (watchedPath) =>
|
ignored: (watchedPath) => {
|
||||||
shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, {
|
if (
|
||||||
|
taskFreshnessDirs.logSignalDirs.some((logSignalDir) =>
|
||||||
|
pathsOverlap(watchedPath, logSignalDir)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, {
|
||||||
scopedSessionIds,
|
scopedSessionIds,
|
||||||
pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)),
|
pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: 250,
|
stabilityThreshold: 250,
|
||||||
pollInterval: 50,
|
pollInterval: 50,
|
||||||
|
|
@ -431,13 +462,13 @@ export class TeamLogSourceTracker {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
|
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
|
||||||
pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir);
|
const eventTaskFreshnessDirs = this.getTaskFreshnessDirsForContext(
|
||||||
|
teamName,
|
||||||
|
current.projectDir,
|
||||||
|
eventTaskFreshnessRootDirs
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
this.handleTaskFreshnessSignalChangeForRoots(
|
this.handleTaskFreshnessSignalChangeForDirs(teamName, changedPath, eventTaskFreshnessDirs)
|
||||||
teamName,
|
|
||||||
changedPath,
|
|
||||||
eventTaskFreshnessRootDirs
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -485,17 +516,19 @@ export class TeamLogSourceTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLogSourceFreshnessDirs(
|
private async ensureLogSourceFreshnessDirs(
|
||||||
|
teamName: string,
|
||||||
transcriptProjectDir: string,
|
transcriptProjectDir: string,
|
||||||
projectDirs: readonly string[]
|
projectDirs: readonly string[]
|
||||||
): Promise<string[]> {
|
): Promise<{ legacyRootDirs: string[]; logSignalDirs: string[] }> {
|
||||||
const watchRootDirs: string[] = [];
|
const legacyRootDirs: string[] = [];
|
||||||
|
const logSignalDirs: string[] = [];
|
||||||
const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir);
|
const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir);
|
||||||
pushUniqueNormalizedPath(watchRootDirs, normalizedTranscriptProjectDir);
|
const teamLogFreshnessDir = getTeamTaskLogFreshnessDir(teamName);
|
||||||
|
pushUniqueNormalizedPath(legacyRootDirs, normalizedTranscriptProjectDir);
|
||||||
|
pushUniqueNormalizedPath(logSignalDirs, teamLogFreshnessDir);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
|
fs.mkdir(teamLogFreshnessDir, { recursive: true }),
|
||||||
recursive: true,
|
|
||||||
}),
|
|
||||||
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}),
|
}),
|
||||||
|
|
@ -511,34 +544,35 @@ export class TeamLogSourceTracker {
|
||||||
if (!(await this.isDirectory(normalizedProjectDir))) {
|
if (!(await this.isDirectory(normalizedProjectDir))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
||||||
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
|
recursive: true,
|
||||||
recursive: true,
|
});
|
||||||
}),
|
pushUniqueNormalizedPath(legacyRootDirs, normalizedProjectDir);
|
||||||
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
|
||||||
recursive: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
pushUniqueNormalizedPath(watchRootDirs, normalizedProjectDir);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`);
|
logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return watchRootDirs;
|
return { legacyRootDirs, logSignalDirs };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildScopedWatchTargets(
|
private async buildScopedWatchTargets(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
confirmedSessionIds: readonly string[],
|
confirmedSessionIds: readonly string[],
|
||||||
pendingRootSessionIds: readonly string[],
|
pendingRootSessionIds: readonly string[],
|
||||||
taskFreshnessRootDirs: readonly string[] = [projectDir]
|
taskFreshnessDirs: {
|
||||||
|
legacyRootDirs: readonly string[];
|
||||||
|
logSignalDirs: readonly string[];
|
||||||
|
} = { legacyRootDirs: [projectDir], logSignalDirs: [] }
|
||||||
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
||||||
const targets = new Set<string>();
|
const targets = new Set<string>();
|
||||||
const scopedSessionIds = new Set<string>();
|
const scopedSessionIds = new Set<string>();
|
||||||
|
|
||||||
targets.add(projectDir);
|
targets.add(projectDir);
|
||||||
for (const freshnessRootDir of taskFreshnessRootDirs) {
|
for (const logSignalDir of taskFreshnessDirs.logSignalDirs) {
|
||||||
|
targets.add(logSignalDir);
|
||||||
|
}
|
||||||
|
for (const freshnessRootDir of taskFreshnessDirs.legacyRootDirs) {
|
||||||
targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
||||||
targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
||||||
}
|
}
|
||||||
|
|
@ -843,6 +877,36 @@ export class TeamLogSourceTracker {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTaskFreshnessDirsForContext(
|
||||||
|
teamName: string,
|
||||||
|
projectDir: string,
|
||||||
|
taskFreshnessRootDirs: readonly string[]
|
||||||
|
): { legacyRootDirs: string[]; logSignalDirs: string[] } {
|
||||||
|
const legacyRootDirs = [...taskFreshnessRootDirs];
|
||||||
|
pushUniqueNormalizedPath(legacyRootDirs, projectDir);
|
||||||
|
return {
|
||||||
|
legacyRootDirs,
|
||||||
|
logSignalDirs: [getTeamTaskLogFreshnessDir(teamName)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTaskFreshnessSignalChangeForDirs(
|
||||||
|
teamName: string,
|
||||||
|
changedPath: string,
|
||||||
|
taskFreshnessDirs: { legacyRootDirs: readonly string[]; logSignalDirs: readonly string[] }
|
||||||
|
): boolean {
|
||||||
|
for (const logSignalDir of taskFreshnessDirs.logSignalDirs) {
|
||||||
|
if (this.handleTaskFreshnessSignalChange(teamName, changedPath, logSignalDir, 'log')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.handleTaskFreshnessSignalChangeForRoots(
|
||||||
|
teamName,
|
||||||
|
changedPath,
|
||||||
|
taskFreshnessDirs.legacyRootDirs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (this.getActiveConsumerCount(state) === 0) {
|
if (this.getActiveConsumerCount(state) === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -7,6 +8,7 @@ import { BoardTaskActivityParseCache } from '../taskLogs/activity/BoardTaskActiv
|
||||||
import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
|
import type { TaskLogFreshnessSignal } from './TeamTaskStallTypes';
|
||||||
|
|
||||||
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
||||||
|
const TEAM_TASK_LOG_FRESHNESS_DIRNAME = 'task-log-freshness';
|
||||||
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
||||||
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
|
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
|
||||||
|
|
||||||
|
|
@ -43,12 +45,14 @@ function taskIdArtifactSegments(taskId: string): string[] {
|
||||||
return safe === legacy ? [safe] : [safe, legacy];
|
return safe === legacy ? [safe] : [safe, legacy];
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskSignalPathCandidates(projectDir: string, taskId: string): string[] {
|
function taskSignalPathCandidates(projectDir: string, taskId: string, teamName?: string): string[] {
|
||||||
return taskIdArtifactSegments(taskId).map((segment) =>
|
const dirs = [
|
||||||
path.join(
|
...(teamName ? [path.join(getTeamsBasePath(), teamName, TEAM_TASK_LOG_FRESHNESS_DIRNAME)] : []),
|
||||||
projectDir,
|
path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME),
|
||||||
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
|
];
|
||||||
`${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`
|
return dirs.flatMap((dir) =>
|
||||||
|
taskIdArtifactSegments(taskId).map((segment) =>
|
||||||
|
path.join(dir, `${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -62,11 +66,12 @@ export class TeamTaskLogFreshnessReader {
|
||||||
|
|
||||||
async readSignals(
|
async readSignals(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
taskIds: string[]
|
taskIds: string[],
|
||||||
|
options?: { teamName?: string }
|
||||||
): Promise<Map<string, TaskLogFreshnessSignal>> {
|
): Promise<Map<string, TaskLogFreshnessSignal>> {
|
||||||
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
|
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
|
||||||
const signalFilePathCandidates = uniqueTaskIds.map((taskId) =>
|
const signalFilePathCandidates = uniqueTaskIds.map((taskId) =>
|
||||||
taskSignalPathCandidates(projectDir, taskId)
|
taskSignalPathCandidates(projectDir, taskId, options?.teamName)
|
||||||
);
|
);
|
||||||
this.cache.retainOnly(new Set(signalFilePathCandidates.flat()));
|
this.cache.retainOnly(new Set(signalFilePathCandidates.flat()));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,8 @@ export class TeamTaskStallSnapshotSource {
|
||||||
const [freshnessByTaskId, exactRowsByFilePath, openCodeEvidence] = await Promise.all([
|
const [freshnessByTaskId, exactRowsByFilePath, openCodeEvidence] = await Promise.all([
|
||||||
this.freshnessReader.readSignals(
|
this.freshnessReader.readSignals(
|
||||||
transcriptContext.projectDir,
|
transcriptContext.projectDir,
|
||||||
relevantMonitorTasks.map((task) => task.id)
|
relevantMonitorTasks.map((task) => task.id),
|
||||||
|
{ teamName }
|
||||||
),
|
),
|
||||||
exactReadsEnabled
|
exactReadsEnabled
|
||||||
? this.exactRowReader.parseFiles(relevantExactFiles)
|
? this.exactRowReader.parseFiles(relevantExactFiles)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import * as path from 'path';
|
||||||
import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot } from '@shared/types';
|
import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot } from '@shared/types';
|
||||||
|
|
||||||
export const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
export const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
||||||
|
export const TEAM_TASK_LOG_FRESHNESS_DIRNAME = 'task-log-freshness';
|
||||||
export const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
|
export const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
|
||||||
export const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
|
export const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
|
||||||
export const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
export const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { tmpdir } from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTeamsBasePath,
|
||||||
|
setClaudeBasePathOverride,
|
||||||
|
} from '../../../../src/main/utils/pathDecoder';
|
||||||
import {
|
import {
|
||||||
shouldIgnoreLogSourceWatcherPath,
|
shouldIgnoreLogSourceWatcherPath,
|
||||||
TeamLogSourceTracker,
|
TeamLogSourceTracker,
|
||||||
|
|
@ -19,6 +23,10 @@ function safeTaskIdSegment(taskId: string): string {
|
||||||
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
|
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teamLogFreshnessDir(teamName = 'demo'): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, 'task-log-freshness');
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamLogSourceTracker', () => {
|
describe('TeamLogSourceTracker', () => {
|
||||||
let tempDir: string | null = null;
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
|
|
@ -28,6 +36,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
if (tempDir) {
|
if (tempDir) {
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
tempDir = null;
|
tempDir = null;
|
||||||
|
|
@ -49,6 +58,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => {
|
it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
|
@ -64,10 +74,10 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
await tracker.enableTracking('demo', 'change_presence');
|
await tracker.enableTracking('demo', 'change_presence');
|
||||||
emitter.mockClear();
|
emitter.mockClear();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
const taskId = '123e4567-e89b-12d3-a456-426614174999';
|
const taskId = '123e4567-e89b-12d3-a456-426614174999';
|
||||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
const signalDir = teamLogFreshnessDir();
|
||||||
await mkdir(signalDir, { recursive: true });
|
await mkdir(signalDir, { recursive: true });
|
||||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
||||||
|
|
||||||
|
|
@ -87,6 +97,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('keeps task-log tracking alive until the last consumer unsubscribes', async () => {
|
it('keeps task-log tracking alive until the last consumer unsubscribes', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
|
@ -103,12 +114,12 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await tracker.enableTracking('demo', 'task_log_stream');
|
await tracker.enableTracking('demo', 'task_log_stream');
|
||||||
await tracker.enableTracking('demo', 'task_log_stream');
|
await tracker.enableTracking('demo', 'task_log_stream');
|
||||||
emitter.mockClear();
|
emitter.mockClear();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
await tracker.disableTracking('demo', 'task_log_stream');
|
await tracker.disableTracking('demo', 'task_log_stream');
|
||||||
|
|
||||||
const taskId = '223e4567-e89b-12d3-a456-426614174999';
|
const taskId = '223e4567-e89b-12d3-a456-426614174999';
|
||||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
const signalDir = teamLogFreshnessDir();
|
||||||
await mkdir(signalDir, { recursive: true });
|
await mkdir(signalDir, { recursive: true });
|
||||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
||||||
|
|
||||||
|
|
@ -129,8 +140,9 @@ describe('TeamLogSourceTracker', () => {
|
||||||
expect(emitter).not.toHaveBeenCalled();
|
expect(emitter).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates transcript freshness dirs without creating missing live cwd roots', async () => {
|
it('creates team log freshness dir without creating missing live cwd roots', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
const transcriptProjectDir = path.join(tempDir, 'transcript-project');
|
const transcriptProjectDir = path.join(tempDir, 'transcript-project');
|
||||||
const missingWorkspaceDir = path.join(tempDir, 'missing-workspace');
|
const missingWorkspaceDir = path.join(tempDir, 'missing-workspace');
|
||||||
|
|
||||||
|
|
@ -152,17 +164,12 @@ describe('TeamLogSourceTracker', () => {
|
||||||
emitter.mockClear();
|
emitter.mockClear();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
expect((await stat(path.join(transcriptProjectDir, '.board-task-log-freshness'))).isDirectory())
|
expect((await stat(teamLogFreshnessDir())).isDirectory()).toBe(true);
|
||||||
.toBe(true);
|
|
||||||
await expect(stat(missingWorkspaceDir)).rejects.toThrow();
|
await expect(stat(missingWorkspaceDir)).rejects.toThrow();
|
||||||
|
|
||||||
const taskId = 'transcript-root-task';
|
const taskId = 'transcript-root-task';
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(
|
path.join(teamLogFreshnessDir(), `${encodeURIComponent(taskId)}.json`),
|
||||||
transcriptProjectDir,
|
|
||||||
'.board-task-log-freshness',
|
|
||||||
`${encodeURIComponent(taskId)}.json`
|
|
||||||
),
|
|
||||||
JSON.stringify({ taskId }),
|
JSON.stringify({ taskId }),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
|
@ -181,6 +188,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => {
|
it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
|
@ -199,7 +207,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
const taskId = 'AUX';
|
const taskId = 'AUX';
|
||||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
const signalDir = teamLogFreshnessDir();
|
||||||
await mkdir(signalDir, { recursive: true });
|
await mkdir(signalDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`),
|
path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`),
|
||||||
|
|
@ -219,8 +227,9 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await tracker.disableTracking('demo', 'task_log_stream');
|
await tracker.disableTracking('demo', 'task_log_stream');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('watches live cwd freshness roots used by Codex Native traces', async () => {
|
it('watches team-scoped log freshness and live cwd task-change freshness roots', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
const transcriptProjectDir = path.join(tempDir, 'transcripts');
|
const transcriptProjectDir = path.join(tempDir, 'transcripts');
|
||||||
const workspaceProjectDir = path.join(tempDir, 'workspace');
|
const workspaceProjectDir = path.join(tempDir, 'workspace');
|
||||||
const memberProjectDir = path.join(tempDir, 'member-workspace');
|
const memberProjectDir = path.join(tempDir, 'member-workspace');
|
||||||
|
|
@ -244,30 +253,15 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
await tracker.enableTracking('demo', 'task_log_stream');
|
await tracker.enableTracking('demo', 'task_log_stream');
|
||||||
emitter.mockClear();
|
emitter.mockClear();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
const logTaskId = 'codex-task-1';
|
await expect(stat(path.join(memberProjectDir, '.board-task-log-freshness'))).rejects.toThrow();
|
||||||
await writeFile(
|
await expect(stat(path.join(workspaceProjectDir, '.board-task-log-freshness'))).rejects.toThrow();
|
||||||
path.join(
|
|
||||||
memberProjectDir,
|
|
||||||
'.board-task-log-freshness',
|
|
||||||
`${encodeURIComponent(logTaskId)}.json`
|
|
||||||
),
|
|
||||||
JSON.stringify({ taskId: logTaskId, source: 'codex-native-trace' }),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(emitter).toHaveBeenCalledWith({
|
|
||||||
type: 'task-log-change',
|
|
||||||
teamName: 'demo',
|
|
||||||
taskId: logTaskId,
|
|
||||||
taskSignalKind: 'log',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
emitter.mockClear();
|
|
||||||
const changeTaskId = 'codex-task-2';
|
const changeTaskId = 'codex-task-2';
|
||||||
|
await mkdir(path.join(workspaceProjectDir, '.board-task-change-freshness'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(
|
path.join(
|
||||||
workspaceProjectDir,
|
workspaceProjectDir,
|
||||||
|
|
@ -432,6 +426,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
|
|
||||||
it('supports stall_monitor as an independent tracking consumer', async () => {
|
it('supports stall_monitor as an independent tracking consumer', async () => {
|
||||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
|
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-stall-monitor-'));
|
||||||
|
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||||
|
|
||||||
const logsFinder = {
|
const logsFinder = {
|
||||||
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
getLiveLogSourceWatchContext: vi.fn(async () => ({
|
||||||
|
|
@ -450,7 +445,7 @@ describe('TeamLogSourceTracker', () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
const taskId = '323e4567-e89b-12d3-a456-426614174999';
|
const taskId = '323e4567-e89b-12d3-a456-426614174999';
|
||||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
const signalDir = teamLogFreshnessDir();
|
||||||
await mkdir(signalDir, { recursive: true });
|
await mkdir(signalDir, { recursive: true });
|
||||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTeamsBasePath,
|
||||||
|
setClaudeBasePathOverride,
|
||||||
|
} from '../../../../../src/main/utils/pathDecoder';
|
||||||
import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader';
|
import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/team/stallMonitor/TeamTaskLogFreshnessReader';
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
@ -13,6 +17,7 @@ function safeTaskIdSegment(taskId: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tempDirs.splice(0).map(async (dirPath) => {
|
tempDirs.splice(0).map(async (dirPath) => {
|
||||||
await fs.rm(dirPath, { recursive: true, force: true });
|
await fs.rm(dirPath, { recursive: true, force: true });
|
||||||
|
|
@ -20,6 +25,10 @@ afterEach(async () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function teamSignalDir(teamName: string): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, 'task-log-freshness');
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamTaskLogFreshnessReader', () => {
|
describe('TeamTaskLogFreshnessReader', () => {
|
||||||
it('reads valid freshness signals and normalizes transcript basename', async () => {
|
it('reads valid freshness signals and normalizes transcript basename', async () => {
|
||||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
|
||||||
|
|
@ -84,6 +93,31 @@ describe('TeamTaskLogFreshnessReader', () => {
|
||||||
expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl');
|
expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers team-scoped freshness signals', async () => {
|
||||||
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
|
||||||
|
tempDirs.push(projectDir);
|
||||||
|
setClaudeBasePathOverride(path.join(projectDir, '.claude'));
|
||||||
|
const signalDir = teamSignalDir('demo');
|
||||||
|
await fs.mkdir(signalDir, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(signalDir, `${encodeURIComponent('task-a')}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
taskId: 'task-a',
|
||||||
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, ['task-a'], {
|
||||||
|
teamName: 'demo',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signals.get('task-a')?.filePath).toBe(
|
||||||
|
path.join(signalDir, `${encodeURIComponent('task-a')}.json`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reads hashed freshness files for very long task ids', async () => {
|
it('reads hashed freshness files for very long task ids', async () => {
|
||||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
|
||||||
tempDirs.push(projectDir);
|
tempDirs.push(projectDir);
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,9 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
tasks: [...expectedWorkflowActiveTasks, ...deletedTasks],
|
tasks: [...expectedWorkflowActiveTasks, ...deletedTasks],
|
||||||
messages: rawMessages,
|
messages: rawMessages,
|
||||||
});
|
});
|
||||||
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
|
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b'], {
|
||||||
|
teamName: 'demo',
|
||||||
|
});
|
||||||
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
|
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
|
||||||
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
|
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
|
||||||
teamName: 'demo',
|
teamName: 'demo',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue