agent-ecosystem/src/main/services/team/taskChangePresenceUtils.ts

163 lines
4.8 KiB
TypeScript

import { deriveTaskSince } from '@shared/utils/taskChangeSince';
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[];
createdAt?: string;
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 effectiveSince =
typeof input.since === 'string'
? input.since
: deriveTaskSince({
createdAt: input.createdAt,
workIntervals: input.intervals,
historyEvents: input.historyEvents as { timestamp?: string | null }[] | undefined,
});
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: effectiveSince ?? '',
};
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,
},
};
}