153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { atomicWriteAsync } from '../atomicWrite';
|
|
import { withFileLock } from '../fileLock';
|
|
|
|
import type {
|
|
TaskStallEvaluation,
|
|
TaskStallJournalEntry,
|
|
TaskStallJournalState,
|
|
} from './TeamTaskStallTypes';
|
|
|
|
function isValidState(value: unknown): value is TaskStallJournalState {
|
|
return value === 'suspected' || value === 'alert_ready' || value === 'alerted';
|
|
}
|
|
|
|
export class TeamTaskStallJournal {
|
|
private getFilePath(teamName: string): string {
|
|
return path.join(getTeamsBasePath(), teamName, 'stall-monitor-journal.json');
|
|
}
|
|
|
|
async reconcileScan(args: {
|
|
teamName: string;
|
|
evaluations: TaskStallEvaluation[];
|
|
activeTaskIds: string[];
|
|
scopeTaskIds?: string[];
|
|
now: string;
|
|
}): Promise<TaskStallEvaluation[]> {
|
|
const filePath = this.getFilePath(args.teamName);
|
|
const readyEvaluations: TaskStallEvaluation[] = [];
|
|
|
|
await withFileLock(filePath, async () => {
|
|
const entries = await this.readUnlocked(filePath);
|
|
const candidateByEpoch = new Map(
|
|
args.evaluations
|
|
.filter(
|
|
(
|
|
evaluation
|
|
): evaluation is TaskStallEvaluation &
|
|
Required<Pick<TaskStallEvaluation, 'taskId' | 'branch' | 'signal' | 'epochKey'>> =>
|
|
evaluation.status === 'alert' &&
|
|
typeof evaluation.taskId === 'string' &&
|
|
typeof evaluation.branch === 'string' &&
|
|
typeof evaluation.signal === 'string' &&
|
|
typeof evaluation.epochKey === 'string'
|
|
)
|
|
.map((evaluation) => [evaluation.epochKey, evaluation] as const)
|
|
);
|
|
|
|
const activeTaskIdSet = new Set(args.activeTaskIds);
|
|
const scopeTaskIdSet = args.scopeTaskIds ? new Set(args.scopeTaskIds) : null;
|
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
const entry = entries[i];
|
|
if (scopeTaskIdSet && !scopeTaskIdSet.has(entry.taskId)) {
|
|
continue;
|
|
}
|
|
if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) {
|
|
entries.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
for (const [epochKey, evaluation] of candidateByEpoch) {
|
|
const existing = entries.find((entry) => entry.epochKey === epochKey);
|
|
if (!existing) {
|
|
entries.push({
|
|
epochKey,
|
|
teamName: args.teamName,
|
|
taskId: evaluation.taskId,
|
|
branch: evaluation.branch,
|
|
signal: evaluation.signal,
|
|
state: 'suspected',
|
|
consecutiveScans: 1,
|
|
createdAt: args.now,
|
|
updatedAt: args.now,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.updatedAt = args.now;
|
|
if (existing.state === 'alerted') {
|
|
continue;
|
|
}
|
|
|
|
existing.consecutiveScans += 1;
|
|
if (existing.consecutiveScans >= 2) {
|
|
existing.state = 'alert_ready';
|
|
readyEvaluations.push(evaluation);
|
|
}
|
|
}
|
|
|
|
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
|
|
});
|
|
|
|
return readyEvaluations;
|
|
}
|
|
|
|
async markAlerted(teamName: string, epochKey: string, now: string): Promise<void> {
|
|
const filePath = this.getFilePath(teamName);
|
|
await withFileLock(filePath, async () => {
|
|
const entries = await this.readUnlocked(filePath);
|
|
const target = entries.find((entry) => entry.epochKey === epochKey);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
target.state = 'alerted';
|
|
target.updatedAt = now;
|
|
target.alertedAt = now;
|
|
await atomicWriteAsync(filePath, JSON.stringify(entries, null, 2));
|
|
});
|
|
}
|
|
|
|
private async readUnlocked(filePath: string): Promise<TaskStallJournalEntry[]> {
|
|
try {
|
|
const raw = await fs.promises.readFile(filePath, 'utf8');
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
|
|
return parsed
|
|
.filter(
|
|
(item): item is TaskStallJournalEntry =>
|
|
item != null &&
|
|
typeof item === 'object' &&
|
|
typeof (item as TaskStallJournalEntry).epochKey === 'string' &&
|
|
typeof (item as TaskStallJournalEntry).teamName === 'string' &&
|
|
typeof (item as TaskStallJournalEntry).taskId === 'string' &&
|
|
((item as TaskStallJournalEntry).branch === 'work' ||
|
|
(item as TaskStallJournalEntry).branch === 'review') &&
|
|
((item as TaskStallJournalEntry).signal === 'turn_ended_after_touch' ||
|
|
(item as TaskStallJournalEntry).signal === 'mid_turn_after_touch' ||
|
|
(item as TaskStallJournalEntry).signal === 'touch_then_other_turns') &&
|
|
isValidState((item as TaskStallJournalEntry).state) &&
|
|
typeof (item as TaskStallJournalEntry).consecutiveScans === 'number' &&
|
|
typeof (item as TaskStallJournalEntry).createdAt === 'string' &&
|
|
typeof (item as TaskStallJournalEntry).updatedAt === 'string'
|
|
)
|
|
.map((entry) => ({
|
|
...entry,
|
|
...(entry.alertedAt ? { alertedAt: entry.alertedAt } : {}),
|
|
}));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return [];
|
|
}
|
|
if (error instanceof SyntaxError) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|